From 710f94a8fe354b25cf6ffdfa3a2591f8274e4c59 Mon Sep 17 00:00:00 2001 From: Alex Bello <77049455+alxbello@users.noreply.github.com> Date: Thu, 23 Apr 2026 01:16:32 -0500 Subject: [PATCH 1/7] feat: add ToolConfig interface, BuiltInTool factories, and Shell permissions Co-authored-by: Kiro Agent <244629292+kiro-agent@users.noreply.github.com> --- packages/kiro-constructs/src/index.ts | 11 ++ .../src/tools/built-in-tools.ts | 112 +++++++++++++++ packages/kiro-constructs/src/tools/index.ts | 13 ++ .../src/tools/shell-commands.ts | 23 +++ packages/kiro-constructs/src/tools/shell.ts | 35 +++++ .../kiro-constructs/src/tools/tool-config.ts | 8 ++ packages/kiro-constructs/test/tools.test.ts | 134 ++++++++++++++++++ 7 files changed, 336 insertions(+) create mode 100644 packages/kiro-constructs/src/tools/built-in-tools.ts create mode 100644 packages/kiro-constructs/src/tools/index.ts create mode 100644 packages/kiro-constructs/src/tools/shell-commands.ts create mode 100644 packages/kiro-constructs/src/tools/shell.ts create mode 100644 packages/kiro-constructs/src/tools/tool-config.ts create mode 100644 packages/kiro-constructs/test/tools.test.ts diff --git a/packages/kiro-constructs/src/index.ts b/packages/kiro-constructs/src/index.ts index 6cd3c61..1516abc 100644 --- a/packages/kiro-constructs/src/index.ts +++ b/packages/kiro-constructs/src/index.ts @@ -15,3 +15,14 @@ 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'; 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..4e50b0c --- /dev/null +++ b/packages/kiro-constructs/src/tools/built-in-tools.ts @@ -0,0 +1,112 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +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..409fe6d --- /dev/null +++ b/packages/kiro-constructs/src/tools/index.ts @@ -0,0 +1,13 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +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..fbba79a --- /dev/null +++ b/packages/kiro-constructs/src/tools/shell-commands.ts @@ -0,0 +1,23 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +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..d147cfb --- /dev/null +++ b/packages/kiro-constructs/src/tools/shell.ts @@ -0,0 +1,35 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +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..6f83326 --- /dev/null +++ b/packages/kiro-constructs/src/tools/tool-config.ts @@ -0,0 +1,8 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +export interface ToolConfig { + readonly toolName: string; + readonly allowed?: boolean; + readonly settings?: Record; +} diff --git a/packages/kiro-constructs/test/tools.test.ts b/packages/kiro-constructs/test/tools.test.ts new file mode 100644 index 0000000..414bd45 --- /dev/null +++ b/packages/kiro-constructs/test/tools.test.ts @@ -0,0 +1,134 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, it, expect } from 'vitest'; +import { BuiltInTool, Shell } from '../src/index.js'; + +describe('ToolConfig and BuiltInTool', () => { + describe('BuiltInTool.shell', () => { + it('returns toolName shell with no settings when called with no args', () => { + expect(BuiltInTool.shell()).toEqual({ toolName: 'shell' }); + }); + + it('includes allowed when specified', () => { + expect(BuiltInTool.shell({ allowed: true })).toEqual({ toolName: 'shell', allowed: true }); + }); + + it('maps allow permissions to allowedCommands in settings', () => { + const result = BuiltInTool.shell({ allow: [Shell.git.readonly()] }); + expect(result.toolName).toBe('shell'); + expect(result.settings?.allowedCommands).toBeInstanceOf(Array); + expect((result.settings?.allowedCommands as string[]).length).toBe(1); + expect((result.settings?.allowedCommands as string[])[0]).toMatch(/^.*git.*status.*$/); + }); + + it('maps deny permissions to deniedCommands in settings', () => { + const result = BuiltInTool.shell({ deny: [Shell.git.destructive()] }); + expect(result.settings?.deniedCommands).toBeInstanceOf(Array); + }); + + it('includes autoAllowReadonly and denyByDefault in settings', () => { + const result = BuiltInTool.shell({ autoAllowReadonly: true, denyByDefault: true }); + expect(result.settings).toEqual({ autoAllowReadonly: true, denyByDefault: true }); + }); + + it('combines multiple permissions by flattening patterns', () => { + const result = BuiltInTool.shell({ allow: [Shell.git.readonly(), Shell.npm.scripts()] }); + expect((result.settings?.allowedCommands as string[]).length).toBe(2); + }); + }); + + describe('BuiltInTool path tools', () => { + it('read returns toolName read', () => { + expect(BuiltInTool.read()).toEqual({ toolName: 'read' }); + }); + + it('write includes path settings', () => { + const result = BuiltInTool.write({ deniedPaths: ['.env'] }); + expect(result).toEqual({ toolName: 'write', settings: { deniedPaths: ['.env'] } }); + }); + + it('glob includes allowReadOnly', () => { + const result = BuiltInTool.glob({ allowReadOnly: true }); + expect(result).toEqual({ toolName: 'glob', settings: { allowReadOnly: true } }); + }); + + it('grep includes allowedPaths and deniedPaths', () => { + const result = BuiltInTool.grep({ allowedPaths: ['/src'], deniedPaths: ['/dist'] }); + expect(result.settings).toEqual({ allowedPaths: ['/src'], deniedPaths: ['/dist'] }); + }); + }); + + describe('BuiltInTool simple tools', () => { + it('aws returns toolName aws', () => { + expect(BuiltInTool.aws()).toEqual({ toolName: 'aws' }); + }); + + it('webFetch uses toolName web_fetch', () => { + expect(BuiltInTool.webFetch()).toEqual({ toolName: 'web_fetch' }); + }); + + it('webSearch uses toolName web_search', () => { + expect(BuiltInTool.webSearch()).toEqual({ toolName: 'web_search' }); + }); + + it('code returns toolName code', () => { + expect(BuiltInTool.code()).toEqual({ toolName: 'code' }); + }); + }); + + describe('BuiltInTool.all', () => { + it('returns exactly 9 tools', () => { + expect(BuiltInTool.all()).toHaveLength(9); + }); + + it('returns all expected tool names', () => { + const names = BuiltInTool.all().map(t => t.toolName); + expect(names).toEqual(['shell', 'read', 'write', 'glob', 'grep', 'aws', 'web_fetch', 'web_search', 'code']); + }); + + it('cascades allowed to all tools', () => { + const tools = BuiltInTool.all({ allowed: true }); + tools.forEach(t => expect(t.allowed).toBe(true)); + }); + + it('allows per-tool override of allowed', () => { + const tools = BuiltInTool.all({ allowed: true, shell: { allowed: false } }); + const shell = tools.find(t => t.toolName === 'shell')!; + expect(shell.allowed).toBe(false); + const rest = tools.filter(t => t.toolName !== 'shell'); + rest.forEach(t => expect(t.allowed).toBe(true)); + }); + + it('passes per-tool settings through', () => { + const tools = BuiltInTool.all({ write: { deniedPaths: ['.env'] } }); + const write = tools.find(t => t.toolName === 'write')!; + expect(write.settings).toEqual({ deniedPaths: ['.env'] }); + }); + }); + + describe('Shell', () => { + it('Shell.command returns custom pattern', () => { + expect(Shell.command('my-cmd.*').patterns).toEqual(['my-cmd.*']); + }); + + it('Shell.git.readonly returns patterns matching git read commands', () => { + const patterns = Shell.git.readonly().patterns; + expect(patterns.length).toBe(1); + expect(patterns[0]).toContain('git'); + expect(patterns[0]).toContain('status'); + }); + + it('Shell.npm.scripts returns patterns matching npm commands', () => { + const patterns = Shell.npm.scripts().patterns; + expect(patterns[0]).toContain('npm'); + expect(patterns[0]).toContain('run'); + }); + + it('Shell.files.inspect returns patterns matching file commands', () => { + const patterns = Shell.files.inspect().patterns; + expect(patterns[0]).toContain('ls'); + expect(patterns[0]).toContain('cat'); + }); + }); +}); From a38801004012916223f4b37a39ae8acbc97514d4 Mon Sep 17 00:00:00 2001 From: Alex Bello <77049455+alxbello@users.noreply.github.com> Date: Thu, 23 Apr 2026 01:56:20 -0500 Subject: [PATCH 2/7] feat: add Agent L2 construct with ToolConfig decomposition - Agent L2 wraps CfgAgent with builder-style composition - ToolConfig interface + BuiltInTool factories for all 9 built-in tools - Shell permission helpers (git, npm, files) with regex patterns - Agent decomposes ToolConfig into tools/allowedTools/toolsSettings - Builder methods: addTool, addMcpServer, addHook, addResource - Lazy deferred rendering, deep-merge with array concat, deduplication Co-authored-by: Kiro Agent <244629292+kiro-agent@users.noreply.github.com> --- .gitignore | 2 + docs/tasks/1-tool-config.md | 351 ++++++++++++++++ docs/tasks/2-agent.md | 343 +++++++++++++++ docs/tasks/l2-constructs.md | 395 ++++++++++++++++++ examples/basic.ts | 3 - examples/tools.ts | 25 ++ packages/kiro-constructs/src/agent.ts | 163 ++++++++ packages/kiro-constructs/src/app.ts | 3 - packages/kiro-constructs/src/cache.ts | 3 - packages/kiro-constructs/src/content.ts | 3 - packages/kiro-constructs/src/index.ts | 4 +- packages/kiro-constructs/src/l1/cfg-agent.ts | 3 - packages/kiro-constructs/src/l1/cfg-prompt.ts | 3 - packages/kiro-constructs/src/lazy.ts | 3 - packages/kiro-constructs/src/logger.ts | 3 - .../kiro-constructs/src/model-provider.ts | 3 - packages/kiro-constructs/src/package-dir.ts | 3 - .../src/providers/bedrock-provider.ts | 3 - .../src/providers/kiro-cli-provider.ts | 3 - .../kiro-constructs/src/synthesis/assembly.ts | 3 - .../kiro-constructs/src/synthesis/source.ts | 3 - .../src/synthesis/synthesizable.ts | 3 - .../src/tools/built-in-tools.ts | 3 - packages/kiro-constructs/src/tools/index.ts | 3 - .../src/tools/shell-commands.ts | 3 - packages/kiro-constructs/src/tools/shell.ts | 3 - .../kiro-constructs/src/tools/tool-config.ts | 3 - packages/kiro-constructs/test/agent.test.ts | 90 ++++ packages/kiro-constructs/test/app.test.ts | 3 - .../test/bedrock-provider.test.ts | 3 - .../kiro-constructs/test/cfg-agent.test.ts | 3 - .../test/kiro-cli-provider.test.ts | 3 - packages/kiro-constructs/test/tools.test.ts | 162 ++----- 33 files changed, 1414 insertions(+), 193 deletions(-) create mode 100644 docs/tasks/1-tool-config.md create mode 100644 docs/tasks/2-agent.md create mode 100644 docs/tasks/l2-constructs.md create mode 100644 examples/tools.ts create mode 100644 packages/kiro-constructs/src/agent.ts create mode 100644 packages/kiro-constructs/test/agent.test.ts diff --git a/.gitignore b/.gitignore index 39687c6..34e80c4 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,5 @@ coverage/ .kiro-constructs.out/ .kiro-constructs.cache/ *.tsbuildinfo + +.idea/ diff --git a/docs/tasks/1-tool-config.md b/docs/tasks/1-tool-config.md new file mode 100644 index 0000000..84fe055 --- /dev/null +++ b/docs/tasks/1-tool-config.md @@ -0,0 +1,351 @@ +# Task 1: ToolConfig Interface, BuiltInTool Factories, and Shell Permissions + +Parent: [l2-constructs.md](./l2-constructs.md) — Task 1 + +## Context + +All source files live in `packages/kiro-constructs/src/`. Tests in `packages/kiro-constructs/test/`. + +Existing conventions: +- No license headers in source files (Apache-2.0 license at repo root is sufficient) +- ESM (`"type": "module"`, `.js` extensions in imports) +- Tests use `vitest` (`describe`, `it`, `expect`), temp dirs for synth tests +- Existing exports in `src/index.ts` — append new exports, don't reorder existing ones + +The `CfgAgent` L1 (in `src/l1/cfg-agent.ts`) already supports these fields that tools wire into: +- `tools: string[]` — list of tool names +- `allowedTools: string[]` — auto-approved tool names +- `toolsSettings: Record` — per-tool settings keyed by tool name + +## Steps + +### Step 1: Create `src/tools/tool-config.ts` + +Create the file with the Apache-2.0 header. Define and export: + +```ts +export interface ToolConfig { + readonly toolName: string; + readonly allowed?: boolean; + readonly settings?: Record; +} +``` + +### Step 2: Create `src/tools/shell-commands.ts` + +Create the file with the Apache-2.0 header. Implement regex builder helpers for safe shell command patterns. + +Define these constants (not exported): + +```ts +const SAFE_ARGS = '( [^;|&`$]+)?'; +const SAFE_PIPE = '( \\| (tail|head)( -[0-9n]+)?| \\| grep( [^;|&`$]+)?)?'; +const STDERR_REDIRECT = '( 2>(&1|/dev/null))?'; +const CD_PREFIX = '(cd [^ ]+ && )?'; +``` + +Implement and export: + +```ts +export function buildPattern(cmd: string, subcommands: string[]): string +// Returns: `^${CD_PREFIX}(${cmd} (${cmds_joined_by_pipe}))${SAFE_ARGS}${STDERR_REDIRECT}${SAFE_PIPE}$` + +export function git(...subcommands: string[]): string +// Calls buildPattern('git', subcommands) + +export function npm(...subcommands: string[]): string +// Calls buildPattern('npm', subcommands) + +export function fileOps(...commands: string[]): string +// Different pattern: `^${CD_PREFIX}(${cmds_joined_by_pipe})${SAFE_ARGS}${STDERR_REDIRECT}${SAFE_PIPE}$` +// Note: fileOps treats each command as a top-level command, not a subcommand +``` + +### Step 3: Create `src/tools/shell.ts` + +Create the file with the Apache-2.0 header. Import `git`, `npm`, `fileOps` from `./shell-commands.js`. + +Define and export: + +```ts +export interface IShellPermission { + readonly patterns: string[]; +} +``` + +Implement `Shell` class with private constructor and static members: + +```ts +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() {} +} +``` + +### Step 4: Create `src/tools/built-in-tools.ts` + +Create the file with the Apache-2.0 header. Import `ToolConfig` from `./tool-config.js` and `IShellPermission` from `./shell.js`. + +Define and export these prop interfaces: + +```ts +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; +} +``` + +Implement `BuiltInTool` class with private constructor: + +- `static shell(props: ShellToolProps = {}): ToolConfig` — Flatten `props.allow` into `settings.allowedCommands` (flatMap `.patterns`). Flatten `props.deny` into `settings.deniedCommands`. Include `autoAllowReadonly` and `denyByDefault` in settings if defined. Return `{ toolName: 'shell', allowed?, settings? }`. Only include `allowed` key if `props.allowed !== undefined`. Only include `settings` key if it has entries. + +- `static read(props: PathToolProps = {}): ToolConfig` — Delegate to private `pathTool('read', props)`. + +- `static write(props: PathToolProps = {}): ToolConfig` — Delegate to private `pathTool('write', props)`. + +- `static glob(props: PathToolWithReadOnlyProps = {}): ToolConfig` — Delegate to private `pathTool('glob', props)`. + +- `static grep(props: PathToolWithReadOnlyProps = {}): ToolConfig` — Delegate to private `pathTool('grep', props)`. + +- `static aws(props: BuiltInToolProps = {}): ToolConfig` — Return `{ toolName: 'aws', allowed? }`. + +- `static webFetch(props: BuiltInToolProps = {}): ToolConfig` — Return `{ toolName: 'web_fetch', allowed? }`. + +- `static webSearch(props: BuiltInToolProps = {}): ToolConfig` — Return `{ toolName: 'web_search', allowed? }`. + +- `static code(props: BuiltInToolProps = {}): ToolConfig` — Return `{ toolName: 'code', allowed? }`. + +- `static all(props: AllToolsProps = {}): ToolConfig[]` — Return array of all 9 tools. For each tool, cascade `props.allowed` as the default `allowed` value, but let per-tool `allowed` override. Example: `BuiltInTool.shell({ ...props.shell, allowed: props.shell?.allowed ?? props.allowed })`. + +- `private static pathTool(toolName: string, props: PathToolWithReadOnlyProps): ToolConfig` — Build settings from `allowedPaths`, `deniedPaths`, `allowReadOnly` (only include keys that are defined and non-empty). Return `{ toolName, allowed?, settings? }`. + +### Step 5: Create `src/tools/index.ts` + +Create the barrel file with the Apache-2.0 header: + +```ts +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'; +``` + +### Step 6: Update `src/index.ts` + +Append these exports at the end of the existing `src/index.ts` (do NOT modify existing exports): + +```ts +export { + type ToolConfig, + BuiltInTool, + type AllToolsProps, + type BuiltInToolProps, + type ShellToolProps, + type PathToolProps, + type PathToolWithReadOnlyProps, + Shell, + type IShellPermission, +} from './tools/index.js'; +``` + +### Step 7: Create `test/tools.test.ts` + +Create the test file with the Apache-2.0 header. Import from `../src/index.js`. Use `describe`/`it`/`expect` from `vitest`. + +Write these test cases: + +``` +describe('ToolConfig and BuiltInTool', () => { + + describe('BuiltInTool.shell', () => { + it('returns toolName shell with no settings when called with no args') + → expect(BuiltInTool.shell()).toEqual({ toolName: 'shell' }) + + it('includes allowed when specified') + → expect(BuiltInTool.shell({ allowed: true })).toEqual({ toolName: 'shell', allowed: true }) + + it('maps allow permissions to allowedCommands in settings') + → result = BuiltInTool.shell({ allow: [Shell.git.readonly()] }) + → expect(result.toolName).toBe('shell') + → expect(result.settings?.allowedCommands).toBeInstanceOf(Array) + → expect((result.settings?.allowedCommands as string[]).length).toBe(1) + → expect((result.settings?.allowedCommands as string[])[0]).toMatch(/^.*git.*status.*$/) + + it('maps deny permissions to deniedCommands in settings') + → result = BuiltInTool.shell({ deny: [Shell.git.destructive()] }) + → expect(result.settings?.deniedCommands).toBeInstanceOf(Array) + + it('includes autoAllowReadonly and denyByDefault in settings') + → result = BuiltInTool.shell({ autoAllowReadonly: true, denyByDefault: true }) + → expect(result.settings).toEqual({ autoAllowReadonly: true, denyByDefault: true }) + + it('combines multiple permissions by flattening patterns') + → result = BuiltInTool.shell({ allow: [Shell.git.readonly(), Shell.npm.scripts()] }) + → expect((result.settings?.allowedCommands as string[]).length).toBe(2) + }) + + describe('BuiltInTool path tools', () => { + it('read returns toolName read') + → expect(BuiltInTool.read()).toEqual({ toolName: 'read' }) + + it('write includes path settings') + → result = BuiltInTool.write({ deniedPaths: ['.env'] }) + → expect(result).toEqual({ toolName: 'write', settings: { deniedPaths: ['.env'] } }) + + it('glob includes allowReadOnly') + → result = BuiltInTool.glob({ allowReadOnly: true }) + → expect(result).toEqual({ toolName: 'glob', settings: { allowReadOnly: true } }) + + it('grep includes allowedPaths and deniedPaths') + → result = BuiltInTool.grep({ allowedPaths: ['/src'], deniedPaths: ['/dist'] }) + → expect(result.settings).toEqual({ allowedPaths: ['/src'], deniedPaths: ['/dist'] }) + }) + + describe('BuiltInTool simple tools', () => { + it('aws returns toolName aws') + → expect(BuiltInTool.aws()).toEqual({ toolName: 'aws' }) + + it('webFetch uses toolName web_fetch') + → expect(BuiltInTool.webFetch()).toEqual({ toolName: 'web_fetch' }) + + it('webSearch uses toolName web_search') + → expect(BuiltInTool.webSearch()).toEqual({ toolName: 'web_search' }) + + it('code returns toolName code') + → expect(BuiltInTool.code()).toEqual({ toolName: 'code' }) + }) + + describe('BuiltInTool.all', () => { + it('returns exactly 9 tools') + → expect(BuiltInTool.all()).toHaveLength(9) + + it('returns all expected tool names') + → names = BuiltInTool.all().map(t => t.toolName) + → expect(names).toEqual(['shell', 'read', 'write', 'glob', 'grep', 'aws', 'web_fetch', 'web_search', 'code']) + + it('cascades allowed to all tools') + → tools = BuiltInTool.all({ allowed: true }) + → tools.forEach(t => expect(t.allowed).toBe(true)) + + it('allows per-tool override of allowed') + → tools = BuiltInTool.all({ allowed: true, shell: { allowed: false } }) + → shell = tools.find(t => t.toolName === 'shell')! + → expect(shell.allowed).toBe(false) + → rest = tools.filter(t => t.toolName !== 'shell') + → rest.forEach(t => expect(t.allowed).toBe(true)) + + it('passes per-tool settings through') + → tools = BuiltInTool.all({ write: { deniedPaths: ['.env'] } }) + → write = tools.find(t => t.toolName === 'write')! + → expect(write.settings).toEqual({ deniedPaths: ['.env'] }) + }) + + describe('Shell', () => { + it('Shell.command returns custom pattern') + → expect(Shell.command('my-cmd.*').patterns).toEqual(['my-cmd.*']) + + it('Shell.git.readonly returns patterns matching git read commands') + → patterns = Shell.git.readonly().patterns + → expect(patterns.length).toBe(1) + → expect(patterns[0]).toContain('git') + → expect(patterns[0]).toContain('status') + + it('Shell.npm.scripts returns patterns matching npm commands') + → patterns = Shell.npm.scripts().patterns + → expect(patterns[0]).toContain('npm') + → expect(patterns[0]).toContain('run') + + it('Shell.files.inspect returns patterns matching file commands') + → patterns = Shell.files.inspect().patterns + → expect(patterns[0]).toContain('ls') + → expect(patterns[0]).toContain('cat') + }) +}) +``` + +### Step 8: Build and test + +Run from the repo root: + +```bash +cd packages/kiro-constructs && npm run build && npm test +``` + +Fix any type errors or test failures. All existing tests must continue to pass. + +## Files Created/Modified + +| File | Action | +|------|--------| +| `src/tools/tool-config.ts` | Create | +| `src/tools/shell-commands.ts` | Create | +| `src/tools/shell.ts` | Create | +| `src/tools/built-in-tools.ts` | Create | +| `src/tools/index.ts` | Create | +| `src/index.ts` | Append exports | +| `test/tools.test.ts` | Create | + +## Commit + +When all tests pass, commit with: + +``` +feat: add ToolConfig interface, BuiltInTool factories, and Shell permissions + +Co-authored-by: Kiro Agent <244629292+kiro-agent@users.noreply.github.com> +``` diff --git a/docs/tasks/2-agent.md b/docs/tasks/2-agent.md new file mode 100644 index 0000000..2f75b72 --- /dev/null +++ b/docs/tasks/2-agent.md @@ -0,0 +1,343 @@ +# Task 2: Agent L2 Construct + +Parent: [l2-constructs.md](./l2-constructs.md) — Task 2 + +## Context + +All source files live in `packages/kiro-constructs/src/`. Tests in `packages/kiro-constructs/test/`. + +Conventions: +- No license headers in source files (Apache-2.0 at repo root) +- ESM (`"type": "module"`, `.js` extensions in imports) +- Tests use `vitest` (`describe`, `it`, `expect`), temp dirs for synth tests +- Existing exports in `src/index.ts` — append new exports, don't reorder existing ones +- Build and test from repo root: `npm run build && npm test` + +The `Agent` L2 wraps the `CfgAgent` L1 and provides builder-style composition. The key feature: it accepts `ToolConfig` objects (from Task 1) and automatically decomposes them into the L1's `tools`, `allowedTools`, and `toolsSettings` fields. + +### Key existing types to use + +`CfgAgent` L1 (in `src/l1/cfg-agent.ts`) accepts: +- `tools: string[]` — tool name strings +- `allowedTools: string[]` — auto-approved tool names +- `toolsSettings: Record` — per-tool settings keyed by name +- `mcpServers: Record` — MCP server configs +- `hooks: CfgAgent.HooksProperty` — lifecycle hooks (agentSpawn, userPromptSubmit, preToolUse, postToolUse, stop) +- `resources: (string | CfgAgent.ResourceProperty)[]` — knowledge base resources +- `description`, `prompt`, `model`, `name`, `toolAliases`, `includeMcpJson`, `keyboardShortcut`, `welcomeMessage` + +`Lazy.any(() => value)` (in `src/lazy.ts`) — defers evaluation until `resolve()` is called during synthesis. Use this for any field that can be mutated after construction via `addX()`. + +`ToolConfig` (in `src/tools/tool-config.ts`): +- `toolName: string` → goes into `tools[]` +- `allowed?: boolean` → if `true`, goes into `allowedTools[]` +- `settings?: Record` → goes into `toolsSettings[toolName]`, deep-merged if same tool added twice + +## Steps + +### Step 1: Create `src/agent.ts` + +Create the file. Import from: +- `constructs` → `Construct` +- `./l1/cfg-agent.js` → `CfgAgent` +- `./lazy.js` → `Lazy` +- `./tools/tool-config.js` → `ToolConfig` + +Define and export the `AgentProps` interface: + +```ts +export interface AgentProps { + readonly name?: string; + readonly description: string; + readonly prompt?: string; + readonly model?: string; + readonly tools?: (string | ToolConfig | ToolConfig[])[]; + readonly mcpServers?: Record; + readonly hooks?: CfgAgent.HooksProperty; + readonly resources?: (string | CfgAgent.ResourceProperty)[]; + readonly toolAliases?: Record; + readonly includeMcpJson?: boolean; + readonly keyboardShortcut?: string; + readonly welcomeMessage?: string; +} +``` + +Implement the `Agent` class extending `Construct`: + +**Private state** — mutable arrays/maps that `addX()` methods push into: +```ts +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** — takes `(scope: Construct, id: string, props: AgentProps)`: +1. Call `super(scope, id)` +2. Set `this.agentName = props.name ?? id` +3. Flatten and push initial `props.tools` into `this._tools` (flatten any nested arrays) +4. Copy initial `props.mcpServers` entries into `this._mcpServers` +5. Copy initial `props.hooks` entries into `this._hooks` +6. Copy initial `props.resources` into `this._resources` +7. Create the L1 internally: + +```ts +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()), +}); +``` + +**Builder methods** — all return `this`: + +- `addTool(tool: string | ToolConfig | ToolConfig[]): this` — if array, flatten and push each; otherwise push directly into `this._tools` +- `addMcpServer(name: string, config: CfgAgent.McpServerProperty): this` — set into `this._mcpServers` +- `addHook(event: string, hook: CfgAgent.HookProperty): this` — append to `this._hooks` for the given event key +- `addResource(resource: string | CfgAgent.ResourceProperty): this` — push into `this._resources` + +**Private render methods** — called lazily during synthesis, return `undefined` when empty (so the key is omitted from JSON): + +- `renderTools(): string[] | undefined` — iterate `this._tools`, extract `toolName` from `ToolConfig` objects, strings pass through. Deduplicate. Return `undefined` if empty. + +- `renderAllowedTools(): string[] | undefined` — iterate `this._tools`, collect `toolName` where `allowed === true`. Deduplicate. Return `undefined` if empty. + +- `renderToolsSettings(): Record | undefined` — iterate `this._tools`, for each `ToolConfig` with `settings`, deep-merge into a map keyed by `toolName`. For deep merge, use a simple recursive merge function (see below). Return `undefined` if empty. + +- `renderMcpServers(): Record | undefined` — convert `this._mcpServers` to object. Return `undefined` if empty. + +- `renderHooks(): CfgAgent.HooksProperty | undefined` — convert `this._hooks` to object. Return `undefined` if empty. + +- `renderResources(): (string | CfgAgent.ResourceProperty)[] | undefined` — return `this._resources` or `undefined` if empty. + +**Deep merge helper** — implement a private static method or standalone function for merging tool settings. Do NOT add `lodash.merge` as a dependency. Instead write a minimal recursive merge: + +```ts +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 (isPlainObject(tVal) && isPlainObject(sVal)) { + result[key] = deepMerge(tVal as Record, sVal as Record); + } else { + result[key] = sVal; + } + } + return result; +} + +function isPlainObject(v: unknown): v is Record { + return typeof v === 'object' && v !== null && !Array.isArray(v); +} +``` + +### Step 2: Update `src/index.ts` + +Append at the end: + +```ts +export { Agent, type AgentProps } from './agent.js'; +``` + +### Step 3: Create `test/agent.test.ts` + +Import from `../src/index.js`: `App`, `Agent`, `BuiltInTool`, `Shell`, `CfgAgent`. +Import `fs`, `os`, `path` from node. +Import `describe`, `it`, `expect`, `beforeEach`, `afterEach` from `vitest`. + +Use the same temp dir pattern as existing tests: + +```ts +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 }); +}); +``` + +Helper to synth and read the agent JSON: + +```ts +async function synthAgent(app: App, name: string) { + await app.synth(); + return JSON.parse(fs.readFileSync(path.join(outdir, 'agents', `${name}.json`), 'utf-8')); +} +``` + +Write these test cases: + +``` +describe('Agent', () => { + + it('synthesizes basic agent with description and prompt') + → new Agent(app, 'dev', { description: 'Dev', prompt: 'You are helpful.' }) + → synth, read agents/dev.json + → expect config.description === 'Dev' + → expect config.prompt === 'You are helpful.' + → expect config.tools to be undefined (no tools added) + + it('accepts string tools') + → new Agent(app, 'dev', { description: 'Dev', tools: ['read', 'write'] }) + → expect config.tools === ['read', 'write'] + → expect config.allowedTools to be undefined + → expect config.toolsSettings to be undefined + + it('decomposes ToolConfig into tools, allowedTools, and toolsSettings') + → new Agent(app, 'dev', { + description: 'Dev', + tools: [ + BuiltInTool.shell({ allowed: true, denyByDefault: true }), + BuiltInTool.write({ deniedPaths: ['.env'] }), + 'read', + ], + }) + → expect config.tools to contain 'shell', 'write', 'read' + → expect config.allowedTools === ['shell'] + → expect config.toolsSettings.shell to deep equal { denyByDefault: true } + → expect config.toolsSettings.write to deep equal { deniedPaths: ['.env'] } + + it('flattens ToolConfig[] from BuiltInTool.all()') + → new Agent(app, 'dev', { description: 'Dev', tools: [BuiltInTool.all()] }) + → expect config.tools to have length 9 + → expect config.tools to contain 'shell', 'read', 'write', 'glob', 'grep', 'aws', 'web_fetch', 'web_search', 'code' + + it('deep-merges settings when same tool added twice') + → const agent = new Agent(app, 'dev', { + description: 'Dev', + tools: [BuiltInTool.shell({ allow: [Shell.git.readonly()] })], + }) + → agent.addTool(BuiltInTool.shell({ deny: [Shell.git.destructive()] })) + → synth + → expect config.toolsSettings.shell to have both allowedCommands and deniedCommands + + it('addTool works after construction') + → const agent = new Agent(app, 'dev', { description: 'Dev' }) + → agent.addTool('shell') + → agent.addTool(BuiltInTool.write({ allowed: true })) + → expect config.tools === ['shell', 'write'] + → expect config.allowedTools === ['write'] + + it('addMcpServer works after construction') + → const agent = new Agent(app, 'dev', { description: 'Dev' }) + → agent.addMcpServer('github', { command: 'gh-mcp', args: ['--token', 'xxx'] }) + → expect config.mcpServers.github === { command: 'gh-mcp', args: ['--token', 'xxx'] } + + it('addHook works after construction') + → const agent = new Agent(app, 'dev', { description: 'Dev' }) + → agent.addHook('agentSpawn', { command: 'node setup.js' }) + → agent.addHook('agentSpawn', { command: 'node warmup.js' }) + → expect config.hooks.agentSpawn to have length 2 + + it('addResource works after construction') + → const agent = new Agent(app, 'dev', { description: 'Dev' }) + → agent.addResource('/docs') + → agent.addResource({ type: 'knowledgeBase', source: '/kb', name: 'docs' }) + → expect config.resources to have length 2 + + it('builder methods return this for chaining') + → const agent = new Agent(app, 'dev', { description: 'Dev' }) + → const result = agent.addTool('shell').addMcpServer('x', { command: 'x' }).addHook('stop', { command: 'y' }).addResource('/z') + → expect result to be agent (same reference) + + it('omits empty fields from output') + → new Agent(app, 'dev', { description: 'Dev' }) + → synth + → expect config to NOT have keys: tools, allowedTools, toolsSettings, mcpServers, hooks, resources + + it('deduplicates tool names') + → new Agent(app, 'dev', { description: 'Dev', tools: ['shell', 'shell', BuiltInTool.shell()] }) + → expect config.tools === ['shell'] (deduplicated) + + it('passes through model, keyboardShortcut, welcomeMessage') + → new Agent(app, 'dev', { + description: 'Dev', + model: 'claude-sonnet', + keyboardShortcut: 'ctrl+shift+d', + welcomeMessage: 'Hello!', + }) + → expect config.model === 'claude-sonnet' + → expect config.keyboardShortcut === 'ctrl+shift+d' + → expect config.welcomeMessage === 'Hello!' + +) +``` + +### Step 4: Update `examples/tools.ts` + +Replace the manual decomposition with the Agent L2. The new example should be: + +```ts +/** + * Tools example: configure an agent with typed tool settings and shell permissions. + * + * Run: npx tsx examples/tools.ts + * Output: .kiro-constructs.out/agents/dev.json + */ + +import { App, Agent, BuiltInTool, Shell } from '@kiro/constructs'; + +const app = new App(); + +new Agent(app, 'dev', { + description: 'Development assistant with fine-grained tool permissions', + prompt: 'You are a helpful development assistant.', + tools: [ + BuiltInTool.all({ allowed: true }), + BuiltInTool.shell({ + allow: [Shell.git.readonly(), Shell.git.write(), Shell.npm.scripts(), Shell.files.inspect()], + deny: [Shell.git.destructive()], + }), + BuiltInTool.write({ deniedPaths: ['.env', '*.pem', '*.key'] }), + ], +}); + +app.synth().then(() => console.log('Synthesized to', app.outdir)); +``` + +### Step 5: Build and test + +Run from the repo root: + +```bash +npm run build && npm test +``` + +Fix any type errors or test failures. All existing tests must continue to pass. + +## Files Created/Modified + +| File | Action | +|------|--------| +| `src/agent.ts` | Create | +| `src/index.ts` | Append export | +| `test/agent.test.ts` | Create | +| `examples/tools.ts` | Replace contents | + +## Commit + +When all tests pass, commit with: + +``` +feat: add Agent L2 construct with ToolConfig decomposition + +Co-authored-by: Kiro Agent <244629292+kiro-agent@users.noreply.github.com> +``` diff --git a/docs/tasks/l2-constructs.md b/docs/tasks/l2-constructs.md new file mode 100644 index 0000000..669da84 --- /dev/null +++ b/docs/tasks/l2-constructs.md @@ -0,0 +1,395 @@ +# L2 Constructs for @kiro/constructs + +## Overview + +Add an opinionated L2 layer on top of the existing L1 constructs (`CfgAgent`, `CfgPrompt`). L2s provide higher-level abstractions with builder-style APIs, sensible defaults, and composition patterns that make agent configuration significantly easier than hand-writing JSON. + +The existing L1s map 1:1 to output files. L2s encode opinions: they manage relationships between agents, skills, and prompts; they use `Lazy` for deferred rendering; and they provide `addX()` methods for incremental composition. + +## Current State + +The repo ships two L1 constructs: + +- `CfgAgent` — synthesizes to `agents/{name}.json` with full config passthrough +- `CfgPrompt` — synthesizes to `prompts/{name}.md` with optional YAML frontmatter + +Plus core infrastructure: `App`, `Assembly`, `Source`, `Lazy`, `Cache`, `Logger`, model providers. + +## What We're Building + +Three L2 construct families — **Agent**, **Skill**, **Prompt** — plus a **Tool** abstraction that lets L2 tool objects wire themselves into agents automatically. + +--- + +## Task 1: `ToolConfig` Interface and `BuiltInTool` Factories + +**Files:** `src/tools/tool-config.ts`, `src/tools/built-in-tools.ts`, `src/tools/shell.ts`, `src/tools/shell-commands.ts` + +The core idea: tools are objects that carry their own name, auto-approval flag, and settings. When you pass a tool to an `Agent`, the agent splits it into the right L1 fields (`tools`, `allowedTools`, `toolsSettings`). + +### ToolConfig Interface + +```ts +export interface ToolConfig { + readonly toolName: string; + readonly allowed?: boolean; + readonly settings?: Record; +} +``` + +This is the contract. Any L2 tool factory returns a `ToolConfig` (or `ToolConfig[]`). The `Agent` L2 accepts `(string | ToolConfig)[]` — strings are bare tool names, `ToolConfig` objects carry settings. + +### How Agent Wires Tools + +When `Agent` receives tools, it decomposes each `ToolConfig` into three L1 fields: + +| ToolConfig field | L1 CfgAgent field | Behavior | +|---|---|---| +| `toolName` | `tools[]` | Always added to the tools list | +| `allowed: true` | `allowedTools[]` | Added to auto-approved list | +| `settings` | `toolsSettings[toolName]` | Merged (deep) if same tool added twice | + +This means you can do: + +```ts +new Agent(app, 'dev', { + tools: [ + 'read', // bare string → just adds to tools[] + BuiltInTool.shell({ allowed: true }), // → tools: ['shell'], allowedTools: ['shell'] + BuiltInTool.write({ deniedPaths: ['.env'] }), // → tools: ['write'], toolsSettings: { write: { deniedPaths: ['.env'] } } + ], +}); +``` + +### BuiltInTool Factories + +Static factory methods that return `ToolConfig` with the correct tool name and settings: + +```ts +export class BuiltInTool { + static shell(props?: ShellToolProps): ToolConfig; + static read(props?: PathToolProps): ToolConfig; + static write(props?: PathToolProps): ToolConfig; + static glob(props?: PathToolWithReadOnlyProps): ToolConfig; + static grep(props?: PathToolWithReadOnlyProps): ToolConfig; + static aws(props?: BuiltInToolProps): ToolConfig; + static webFetch(props?: BuiltInToolProps): ToolConfig; + static webSearch(props?: BuiltInToolProps): ToolConfig; + static code(props?: BuiltInToolProps): ToolConfig; + static all(props?: AllToolsProps): ToolConfig[]; +} +``` + +Props per tool type: + +```ts +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; +} +``` + +`ShellToolProps.allow` and `.deny` accept `IShellPermission` objects — these are typed shell permission providers that expand to regex patterns in `toolsSettings.shell.allowedCommands` / `deniedCommands`. + +### Shell Permissions + +```ts +export interface IShellPermission { + readonly patterns: string[]; +} + +export class Shell { + static readonly git: { + readonly(): IShellPermission; + write(): IShellPermission; + destructive(): IShellPermission; + }; + static readonly npm: { + scripts(): IShellPermission; + }; + static readonly files: { + inspect(): IShellPermission; + }; + static command(pattern: string): IShellPermission; +} +``` + +Usage: + +```ts +BuiltInTool.shell({ + allowed: true, + allow: [Shell.git.readonly(), Shell.npm.scripts()], + deny: [Shell.git.destructive()], +}) +``` + +This produces: + +```json +{ + "tools": ["shell"], + "allowedTools": ["shell"], + "toolsSettings": { + "shell": { + "allowedCommands": ["^(git (status|log|diff|...))...$", "^(npm (run|test|...))...$"], + "deniedCommands": ["^(git (push --force|reset --hard|...))...$"] + } + } +} +``` + +### `BuiltInTool.all()` — Convenience Bundle + +Returns all 9 built-in tools. Per-tool overrides via named props: + +```ts +BuiltInTool.all({ + allowed: true, // default for all + shell: { allow: [Shell.git.readonly()] }, + write: { deniedPaths: ['.env', '*.pem'] }, +}) +``` + +### Acceptance Criteria + +- [ ] `ToolConfig` interface exported +- [ ] `BuiltInTool` static factories produce correct `ToolConfig` objects +- [ ] `Shell` permission helpers produce correct regex patterns +- [ ] `BuiltInTool.all()` returns 9 tools with per-tool overrides +- [ ] Unit tests: each factory, shell permissions, `all()` with overrides + +--- + +## Task 2: `Agent` L2 Construct + +**File:** `src/agent.ts` + +A higher-level agent construct that wraps `CfgAgent` and provides builder-style composition. The key feature: it accepts `ToolConfig` objects and decomposes them into the right L1 fields. + +### Interface + +```ts +export interface AgentProps { + readonly name?: string; + readonly description: string; + readonly prompt?: string; + readonly model?: string; + readonly tools?: (string | ToolConfig | ToolConfig[])[]; + readonly mcpServers?: Record; + readonly hooks?: HooksConfig; + readonly resources?: (string | ResourceConfig)[]; +} +``` + +Note `tools` accepts `ToolConfig[]` too (from `BuiltInTool.all()`), which gets flattened. + +### Behavior + +- Implements `ISynthesizable` — creates a `CfgAgent` L1 internally during `synthesize()` +- Uses `Lazy` to defer rendering so `addX()` calls work after construction +- `addTool(tool: string | ToolConfig | ToolConfig[])` — flattens arrays, splits into tools/allowedTools/toolsSettings +- `addMcpServer(name, config)` — adds to MCP servers map +- `addHook(event, entry)` — appends to hooks for the given lifecycle event +- `addResource(resource)` — appends to resources list +- All `addX()` methods return `this` for chaining +- When the same tool is added twice, settings are deep-merged (via `lodash.merge` or manual spread) + +### Example + +```ts +const agent = new Agent(app, 'dev', { + description: 'Development assistant', + prompt: 'You are a helpful development assistant.', + tools: [ + BuiltInTool.all({ allowed: true }), + BuiltInTool.shell({ + allow: [Shell.git.readonly(), Shell.npm.scripts()], + deny: [Shell.git.destructive()], + }), + ], +}); + +agent.addMcpServer('github', { + command: 'gh-mcp', + env: { GITHUB_TOKEN: '${GITHUB_TOKEN}' }, +}); +``` + +### Acceptance Criteria + +- [ ] `Agent` synthesizes identical JSON to an equivalent `CfgAgent` +- [ ] `addTool()` / `addMcpServer()` / `addHook()` / `addResource()` work after construction +- [ ] `ToolConfig` objects correctly split into `tools`, `allowedTools`, `toolsSettings` +- [ ] Tool settings deep-merge when same tool added twice +- [ ] `ToolConfig[]` (from `BuiltInTool.all()`) flattened correctly +- [ ] Unit tests: basic synth, builder methods, tool decomposition, tool merging, empty agent + +--- + +## Task 3: `Skill` L2 Construct + +**File:** `src/skill.ts` + +A construct that synthesizes a Kiro skill directory with a `SKILL.md` file (YAML frontmatter + markdown body) and optional asset files. + +### Interface + +```ts +export interface SkillProps { + readonly name?: string; + readonly description: string; + readonly instructions: string; + readonly assets?: Record; + readonly metadata?: Record; +} +``` + +### Behavior + +- Implements `ISynthesizable` +- Synthesizes to `skills/{name}/SKILL.md` with YAML frontmatter containing `name`, `description`, and any extra `metadata` +- Body is the `instructions` content +- Optional `assets` map writes additional files into the skill directory +- `Skill.fromDirectory()` static factory reads an existing skill directory from disk, parsing `SKILL.md` frontmatter + +### Example + +```ts +const skill = new Skill(app, 'typescript', { + description: 'TypeScript development expertise', + instructions: '# TypeScript\n\nFollow strict TypeScript conventions...', + assets: { + 'tsconfig.example.json': '{ "strict": true }', + }, +}); + +// Load from existing directory +const existing = Skill.fromDirectory(app, 'review', './skills/review'); +``` + +### Acceptance Criteria + +- [ ] Synthesizes `skills/{name}/SKILL.md` with correct frontmatter +- [ ] Assets written to skill subdirectory +- [ ] `fromDirectory()` parses existing SKILL.md frontmatter and content +- [ ] Unit tests: basic synth, assets, fromDirectory + +--- + +## Task 4: `Prompt` L2 Construct + +**File:** `src/prompt.ts` + +A thin L2 wrapper over `CfgPrompt` that adds builder methods and a `fromFile()` factory. + +### Interface + +```ts +export interface PromptProps { + readonly name?: string; + readonly content: string; + readonly metadata?: Record; +} +``` + +### Behavior + +- Wraps `CfgPrompt` — delegates synthesis entirely +- `Prompt.fromFile()` static factory reads a markdown file from disk, parsing YAML frontmatter into metadata and body into content +- Provides `appendContent()` method for post-construction content additions (uses `Lazy` internally) + +### Example + +```ts +const prompt = new Prompt(app, 'review', { + content: '# Code Review\n\nReview for correctness and readability.', + metadata: { version: '2.0' }, +}); + +// Load from file +const fromDisk = Prompt.fromFile(app, 'security', './prompts/security-review.md'); +``` + +### Acceptance Criteria + +- [ ] Synthesizes identically to equivalent `CfgPrompt` +- [ ] `fromFile()` correctly parses frontmatter and content +- [ ] `appendContent()` works after construction +- [ ] Unit tests: basic synth, fromFile, appendContent + +--- + +## Task 5: Exports + +Update `src/index.ts` to add all new L2 exports alongside existing ones. + +### Acceptance Criteria + +- [ ] All L2 constructs importable from `@kiro/constructs` +- [ ] No circular dependencies + +--- + +## Implementation Notes + +### File Layout + +Everything lives in `packages/kiro-constructs` — single package, flat exports, domain folders internally. + +``` +packages/kiro-constructs/src/ +├── l1/ +│ ├── cfg-agent.ts # existing +│ └── cfg-prompt.ts # existing +├── tools/ +│ ├── index.ts # barrel +│ ├── tool-config.ts # ToolConfig interface +│ ├── built-in-tools.ts # BuiltInTool factories +│ ├── shell.ts # Shell permissions +│ └── shell-commands.ts # regex builders +├── agent.ts # Agent L2 +├── skill.ts # Skill L2 +├── prompt.ts # Prompt L2 +├── index.ts # add new exports alongside existing ones +└── ...existing files +``` + +All L2s export from the package root: `import { App, Agent, Skill, BuiltInTool, CfgAgent } from '@kiro/constructs'` + +### Design Principles + +1. **L2s compose L1s** — they don't bypass them. `Agent` creates a `CfgAgent` internally. `Prompt` wraps `CfgPrompt`. +2. **Tools are data** — `ToolConfig` is a plain object, not a construct. Tool factories (`BuiltInTool.shell()`) return data that the `Agent` knows how to decompose into L1 fields. +3. **Lazy rendering** — use `Lazy.any()` for any property that can be modified after construction via `addX()` methods. +4. **Static factories** — `fromFile()` and `fromDirectory()` for loading existing config from disk. Resolve paths relative to `App.sourceDir`. +5. **ISynthesizable** — L2s that produce output implement this interface. The `App` tree walk handles the rest. +6. **No breaking changes** — L1s remain the same. L2s are additive. + +### Dependencies + +- `gray-matter` — already in the repo for `CfgPrompt` frontmatter parsing. Reuse for `Skill` and `Prompt.fromFile()`. +- `lodash.merge` — needed for `Agent` tool settings merging. Add as a dependency. + +### Suggested Order + +1. Task 1 (ToolConfig + BuiltInTool + Shell) — foundation for Agent +2. Task 5 (exports) — wire up index.ts +3. Task 4 (Prompt) — simplest L2, validates the pattern +4. Task 2 (Agent) — core L2 with tool decomposition +5. Task 3 (Skill) — synthesizes to its own directory structure 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/examples/tools.ts b/examples/tools.ts new file mode 100644 index 0000000..8691a1b --- /dev/null +++ b/examples/tools.ts @@ -0,0 +1,25 @@ +/** + * Tools example: configure an agent with typed tool settings and shell permissions. + * + * Run: npx tsx examples/tools.ts + * Output: .kiro-constructs.out/agents/dev.json + */ + +import { App, Agent, BuiltInTool, Shell } from '@kiro/constructs'; + +const app = new App(); + +new Agent(app, 'dev', { + description: 'Development assistant with fine-grained tool permissions', + prompt: 'You are a helpful development assistant.', + tools: [ + BuiltInTool.all({ allowed: true }), + BuiltInTool.shell({ + allow: [Shell.git.readonly(), Shell.git.write(), Shell.npm.scripts(), Shell.files.inspect()], + deny: [Shell.git.destructive()], + }), + BuiltInTool.write({ deniedPaths: ['.env', '*.pem', '*.key'] }), + ], +}); + +app.synth().then(() => console.log('Synthesized to', app.outdir)); diff --git a/packages/kiro-constructs/src/agent.ts b/packages/kiro-constructs/src/agent.ts new file mode 100644 index 0000000..4197cf1 --- /dev/null +++ b/packages/kiro-constructs/src/agent.ts @@ -0,0 +1,163 @@ +import { Construct } from 'constructs'; +import { CfgAgent } from './l1/cfg-agent.js'; +import { Lazy } from './lazy.js'; +import type { ToolConfig } from './tools/tool-config.js'; + +export interface AgentProps { + readonly name?: string; + readonly description: string; + readonly prompt?: string; + readonly model?: string; + readonly tools?: (string | ToolConfig | ToolConfig[])[]; + 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, ...sVal]; + } else if (isPlainObject(tVal) && isPlainObject(sVal)) { + result[key] = deepMerge(tVal as Record, sVal as Record); + } 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]); + } + } + + if (props.resources) { + this._resources.push(...props.resources); + } + + 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; + } + + 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 1516abc..3295b83 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'; @@ -26,3 +23,4 @@ export { Shell, type IShellPermission, } from './tools/index.js'; +export { Agent, type AgentProps } from './agent.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/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/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 index 4e50b0c..9a39687 100644 --- a/packages/kiro-constructs/src/tools/built-in-tools.ts +++ b/packages/kiro-constructs/src/tools/built-in-tools.ts @@ -1,6 +1,3 @@ -// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 - import type { ToolConfig } from './tool-config.js'; import type { IShellPermission } from './shell.js'; diff --git a/packages/kiro-constructs/src/tools/index.ts b/packages/kiro-constructs/src/tools/index.ts index 409fe6d..5b62dd0 100644 --- a/packages/kiro-constructs/src/tools/index.ts +++ b/packages/kiro-constructs/src/tools/index.ts @@ -1,6 +1,3 @@ -// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 - export { type ToolConfig } from './tool-config.js'; export { BuiltInTool, diff --git a/packages/kiro-constructs/src/tools/shell-commands.ts b/packages/kiro-constructs/src/tools/shell-commands.ts index fbba79a..fea21cf 100644 --- a/packages/kiro-constructs/src/tools/shell-commands.ts +++ b/packages/kiro-constructs/src/tools/shell-commands.ts @@ -1,6 +1,3 @@ -// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 - const SAFE_ARGS = '( [^;|&`$]+)?'; const SAFE_PIPE = '( \\| (tail|head)( -[0-9n]+)?| \\| grep( [^;|&`$]+)?)?'; const STDERR_REDIRECT = '( 2>(&1|/dev/null))?'; diff --git a/packages/kiro-constructs/src/tools/shell.ts b/packages/kiro-constructs/src/tools/shell.ts index d147cfb..8341ec4 100644 --- a/packages/kiro-constructs/src/tools/shell.ts +++ b/packages/kiro-constructs/src/tools/shell.ts @@ -1,6 +1,3 @@ -// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 - import { git, npm, fileOps } from './shell-commands.js'; export interface IShellPermission { diff --git a/packages/kiro-constructs/src/tools/tool-config.ts b/packages/kiro-constructs/src/tools/tool-config.ts index 6f83326..d22730e 100644 --- a/packages/kiro-constructs/src/tools/tool-config.ts +++ b/packages/kiro-constructs/src/tools/tool-config.ts @@ -1,6 +1,3 @@ -// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 - export interface ToolConfig { readonly toolName: string; readonly allowed?: boolean; diff --git a/packages/kiro-constructs/test/agent.test.ts b/packages/kiro-constructs/test/agent.test.ts new file mode 100644 index 0000000..31e7384 --- /dev/null +++ b/packages/kiro-constructs/test/agent.test.ts @@ -0,0 +1,90 @@ +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, 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) { + await app.synth(); + return JSON.parse(fs.readFileSync(path.join(outdir, 'agents', `${name}.json`), 'utf-8')); +} + +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']); + expect(config.toolsSettings.shell).toEqual({ denyByDefault: true }); + expect(config.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'); + expect(config.toolsSettings.shell.allowedCommands).toHaveLength(2); + expect(config.toolsSettings.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']); + expect(config.mcpServers.github).toEqual({ command: 'gh-mcp' }); + expect(config.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'); + }); +}); 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/tools.test.ts b/packages/kiro-constructs/test/tools.test.ts index 414bd45..279030b 100644 --- a/packages/kiro-constructs/test/tools.test.ts +++ b/packages/kiro-constructs/test/tools.test.ts @@ -1,134 +1,60 @@ -// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 - import { describe, it, expect } from 'vitest'; import { BuiltInTool, Shell } from '../src/index.js'; -describe('ToolConfig and BuiltInTool', () => { - describe('BuiltInTool.shell', () => { - it('returns toolName shell with no settings when called with no args', () => { - expect(BuiltInTool.shell()).toEqual({ toolName: 'shell' }); - }); - - it('includes allowed when specified', () => { - expect(BuiltInTool.shell({ allowed: true })).toEqual({ toolName: 'shell', allowed: true }); - }); - - it('maps allow permissions to allowedCommands in settings', () => { - const result = BuiltInTool.shell({ allow: [Shell.git.readonly()] }); - expect(result.toolName).toBe('shell'); - expect(result.settings?.allowedCommands).toBeInstanceOf(Array); - expect((result.settings?.allowedCommands as string[]).length).toBe(1); - expect((result.settings?.allowedCommands as string[])[0]).toMatch(/^.*git.*status.*$/); - }); - - it('maps deny permissions to deniedCommands in settings', () => { - const result = BuiltInTool.shell({ deny: [Shell.git.destructive()] }); - expect(result.settings?.deniedCommands).toBeInstanceOf(Array); - }); - - it('includes autoAllowReadonly and denyByDefault in settings', () => { - const result = BuiltInTool.shell({ autoAllowReadonly: true, denyByDefault: true }); - expect(result.settings).toEqual({ autoAllowReadonly: true, denyByDefault: true }); - }); - - it('combines multiple permissions by flattening patterns', () => { - const result = BuiltInTool.shell({ allow: [Shell.git.readonly(), Shell.npm.scripts()] }); - expect((result.settings?.allowedCommands as string[]).length).toBe(2); - }); +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); }); - describe('BuiltInTool path tools', () => { - it('read returns toolName read', () => { - expect(BuiltInTool.read()).toEqual({ toolName: 'read' }); - }); - - it('write includes path settings', () => { - const result = BuiltInTool.write({ deniedPaths: ['.env'] }); - expect(result).toEqual({ toolName: 'write', settings: { deniedPaths: ['.env'] } }); - }); - - it('glob includes allowReadOnly', () => { - const result = BuiltInTool.glob({ allowReadOnly: true }); - expect(result).toEqual({ toolName: 'glob', settings: { allowReadOnly: true } }); + it('path tools map allowedPaths, deniedPaths, and allowReadOnly to settings', () => { + expect(BuiltInTool.write({ deniedPaths: ['.env'] })).toEqual({ + toolName: 'write', + settings: { deniedPaths: ['.env'] }, }); - - it('grep includes allowedPaths and deniedPaths', () => { - const result = BuiltInTool.grep({ allowedPaths: ['/src'], deniedPaths: ['/dist'] }); - expect(result.settings).toEqual({ allowedPaths: ['/src'], deniedPaths: ['/dist'] }); + expect(BuiltInTool.glob({ allowReadOnly: true })).toEqual({ + toolName: 'glob', + settings: { allowReadOnly: true }, }); }); - describe('BuiltInTool simple tools', () => { - it('aws returns toolName aws', () => { - expect(BuiltInTool.aws()).toEqual({ toolName: 'aws' }); - }); - - it('webFetch uses toolName web_fetch', () => { - expect(BuiltInTool.webFetch()).toEqual({ toolName: 'web_fetch' }); - }); - - it('webSearch uses toolName web_search', () => { - expect(BuiltInTool.webSearch()).toEqual({ toolName: 'web_search' }); - }); - - it('code returns toolName code', () => { - expect(BuiltInTool.code()).toEqual({ toolName: 'code' }); - }); + it('simple tools use correct toolNames', () => { + expect(BuiltInTool.webFetch().toolName).toBe('web_fetch'); + expect(BuiltInTool.webSearch().toolName).toBe('web_search'); }); - describe('BuiltInTool.all', () => { - it('returns exactly 9 tools', () => { - expect(BuiltInTool.all()).toHaveLength(9); - }); - - it('returns all expected tool names', () => { - const names = BuiltInTool.all().map(t => t.toolName); - expect(names).toEqual(['shell', 'read', 'write', 'glob', 'grep', 'aws', 'web_fetch', 'web_search', 'code']); - }); - - it('cascades allowed to all tools', () => { - const tools = BuiltInTool.all({ allowed: true }); - tools.forEach(t => expect(t.allowed).toBe(true)); - }); - - it('allows per-tool override of allowed', () => { - const tools = BuiltInTool.all({ allowed: true, shell: { allowed: false } }); - const shell = tools.find(t => t.toolName === 'shell')!; - expect(shell.allowed).toBe(false); - const rest = tools.filter(t => t.toolName !== 'shell'); - rest.forEach(t => expect(t.allowed).toBe(true)); - }); - - it('passes per-tool settings through', () => { - const tools = BuiltInTool.all({ write: { deniedPaths: ['.env'] } }); - const write = tools.find(t => t.toolName === 'write')!; - expect(write.settings).toEqual({ deniedPaths: ['.env'] }); - }); + 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'); }); - describe('Shell', () => { - it('Shell.command returns custom pattern', () => { - expect(Shell.command('my-cmd.*').patterns).toEqual(['my-cmd.*']); - }); - - it('Shell.git.readonly returns patterns matching git read commands', () => { - const patterns = Shell.git.readonly().patterns; - expect(patterns.length).toBe(1); - expect(patterns[0]).toContain('git'); - expect(patterns[0]).toContain('status'); - }); - - it('Shell.npm.scripts returns patterns matching npm commands', () => { - const patterns = Shell.npm.scripts().patterns; - expect(patterns[0]).toContain('npm'); - expect(patterns[0]).toContain('run'); - }); + 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); + }); +}); - it('Shell.files.inspect returns patterns matching file commands', () => { - const patterns = Shell.files.inspect().patterns; - expect(patterns[0]).toContain('ls'); - expect(patterns[0]).toContain('cat'); - }); +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']); }); }); From 4f64286c58c30d5067c00c586b80f76ecdab5e62 Mon Sep 17 00:00:00 2001 From: Alex Bello <77049455+alxbello@users.noreply.github.com> Date: Thu, 23 Apr 2026 14:01:38 -0500 Subject: [PATCH 3/7] feat: add Skill and Prompt L2 constructs Co-authored-by: Kiro Agent <244629292+kiro-agent@users.noreply.github.com> --- README.md | 118 ++++++++- docs/tasks/3-skill-prompt.md | 258 +++++++++++++++++++ docs/tasks/l2-constructs.md | 34 ++- examples/agent-with-skills.ts | 52 ++++ examples/tools.ts | 25 -- packages/kiro-constructs/src/agent.ts | 22 ++ packages/kiro-constructs/src/index.ts | 2 + packages/kiro-constructs/src/prompt.ts | 52 ++++ packages/kiro-constructs/src/skill.ts | 62 +++++ packages/kiro-constructs/test/agent.test.ts | 15 +- packages/kiro-constructs/test/prompt.test.ts | 53 ++++ packages/kiro-constructs/test/skill.test.ts | 66 +++++ 12 files changed, 717 insertions(+), 42 deletions(-) create mode 100644 docs/tasks/3-skill-prompt.md create mode 100644 examples/agent-with-skills.ts delete mode 100644 examples/tools.ts create mode 100644 packages/kiro-constructs/src/prompt.ts create mode 100644 packages/kiro-constructs/src/skill.ts create mode 100644 packages/kiro-constructs/test/prompt.test.ts create mode 100644 packages/kiro-constructs/test/skill.test.ts 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/docs/tasks/3-skill-prompt.md b/docs/tasks/3-skill-prompt.md new file mode 100644 index 0000000..2313371 --- /dev/null +++ b/docs/tasks/3-skill-prompt.md @@ -0,0 +1,258 @@ +# Task 3+4: Skill L2, Prompt L2, and L2 Example + +Parent: [l2-constructs.md](./l2-constructs.md) — Tasks 3, 4, 5 + +## Context + +All source files live in `packages/kiro-constructs/src/`. Tests in `packages/kiro-constructs/test/`. + +Conventions: +- No license headers in source files (Apache-2.0 at repo root) +- ESM (`"type": "module"`, `.js` extensions in imports) +- Tests use `vitest` (`describe`, `it`, `expect`), temp dirs for synth tests +- Build and test from repo root: `npm run build && npm test` +- `gray-matter` is already a dependency — use it for frontmatter parsing +- Keep tests focused — one test per distinct behavior, combine related assertions + +## Steps + +### Step 1: Create `src/skill.ts` + +Import from: +- `constructs` → `Construct` +- `./synthesis/assembly.js` → `IAssembly` +- `./synthesis/synthesizable.js` → `ISynthesizable` +- `./synthesis/source.js` → `Source` +- `gray-matter` → `matter` +- `node:fs` and `node:path` + +Define and export: + +```ts +export interface SkillProps { + readonly name?: string; + readonly description: string; + readonly instructions: string; + readonly assets?: Record; + readonly metadata?: Record; +} +``` + +Implement `Skill` class extending `Construct` implementing `ISynthesizable`: + +- `readonly skillName: string` (public, set from `props.name ?? id`) +- Store `description`, `instructions`, `assets`, `metadata` from props + +**`synthesize(assembly: IAssembly)`:** +1. Create a sub-assembly: `assembly.subAssembly('skills').subAssembly(this.skillName)` +2. Build frontmatter object: `{ name: this.skillName, description: this.description, ...this.metadata }` +3. Use `matter.stringify('\n' + this.instructions, frontmatter)` to produce the SKILL.md content +4. Write `SKILL.md` via `Source.text()` +5. If `assets` is defined, write each entry as `Source.text(value)` to the skill sub-assembly + +**`static fromDirectory(scope: Construct, id: string, dirPath: string): Skill`:** +1. Resolve `dirPath` relative to `App.sourceDir` using `App.of(scope).sourceDir` +2. Read `SKILL.md` from the directory using `fs.readFileSync` +3. Parse with `matter()` to extract frontmatter and content +4. Return `new Skill(scope, id, { name: frontmatter.name ?? id, description: frontmatter.description, instructions: content.trim(), metadata: remaining frontmatter fields })` + +### Step 2: Create `src/prompt.ts` + +Import from: +- `constructs` → `Construct` +- `./l1/cfg-prompt.js` → `CfgPrompt` +- `./lazy.js` → `Lazy` +- `gray-matter` → `matter` +- `node:fs` and `node:path` + +Define and export: + +```ts +export interface PromptProps { + readonly name?: string; + readonly content: string; + readonly metadata?: Record; +} +``` + +Implement `Prompt` class extending `Construct`: + +- Store `_content: string` and `_appendedContent: string[]` (for `appendContent`) +- In constructor, create the L1 internally: + +```ts +new CfgPrompt(this, 'Resource', { + name: props.name ?? id, + content: Lazy.any(() => this.renderContent()), + metadata: props.metadata, +}); +``` + +**`appendContent(content: string): this`** — push to `_appendedContent`, return `this` + +**`private renderContent(): string`** — return `this._content` + joined appended content (separated by `\n\n`) + +**`static fromFile(scope: Construct, id: string, filePath: string): Prompt`:** +1. Resolve `filePath` relative to `App.of(scope).sourceDir` +2. Read file with `fs.readFileSync` +3. Parse with `matter()` to extract frontmatter and content +4. Return `new Prompt(scope, id, { name: frontmatter.name ?? id, content: content.trim(), metadata: remaining frontmatter fields })` + +### Step 3: Update `src/index.ts` + +Append at the end: + +```ts +export { Skill, type SkillProps } from './skill.js'; +export { Prompt, type PromptProps } from './prompt.js'; +``` + +### Step 4: Create `test/skill.test.ts` + +Write these test cases: + +``` +describe('Skill', () => { + + it('synthesizes SKILL.md with frontmatter and instructions') + → new Skill(app, 'typescript', { description: 'TS expertise', instructions: '# TypeScript\n\nUse strict mode.' }) + → synth, read skills/typescript/SKILL.md + → parse with matter() + → expect data.name === 'typescript' + → expect data.description === 'TS expertise' + → expect content.trim() to contain '# TypeScript' + + it('writes assets to skill subdirectory') + → new Skill(app, 'ts', { description: 'TS', instructions: 'instructions', assets: { 'example.json': '{}' } }) + → synth + → expect skills/ts/example.json to exist and equal '{}' + + it('fromDirectory reads existing SKILL.md') + → write a SKILL.md with frontmatter to a temp dir + → Skill.fromDirectory(app, 'loaded', tempSkillDir) + → synth, read the output SKILL.md + → expect frontmatter and content match what was written + +) +``` + +### Step 5: Create `test/prompt.test.ts` + +Write these test cases: + +``` +describe('Prompt', () => { + + it('synthesizes identically to CfgPrompt') + → new Prompt(app, 'review', { content: '# Review\n\nCheck code.' }) + → synth, read prompts/review.md + → expect content === '# Review\n\nCheck code.' + + it('appendContent adds content after construction') + → const p = new Prompt(app, 'review', { content: '# Review' }) + → p.appendContent('## Section 2') + → synth, read prompts/review.md + → expect content to contain both '# Review' and '## Section 2' + + it('fromFile reads existing markdown with frontmatter') + → write a markdown file with frontmatter to temp dir + → Prompt.fromFile(app, 'loaded', tempFilePath) + → synth, read the output + → expect content and metadata match + +) +``` + +### Step 6: Create `examples/l2.ts` + +Create a single example showing all L2 constructs together: + +```ts +/** + * L2 constructs example: Agent, Skill, and Prompt working together. + * + * 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(); + +new Agent(app, 'dev', { + description: 'Full-stack development assistant', + prompt: 'You are a senior full-stack developer.', + 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'] }), + ], +}); + +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'), +}); + +new Prompt(app, 'review', { + content: [ + '# Code Review', + '', + 'Review the code for:', + '- Correctness and edge cases', + '- Performance implications', + '- Readability and naming', + ].join('\n'), +}); + +app.synth().then(() => console.log('Synthesized to', app.outdir)); +``` + +### Step 7: Build and test + +Run from the repo root: + +```bash +npm run build && npm test +``` + +Also run the example to verify output: + +```bash +npx tsx examples/l2.ts +``` + +Fix any type errors or test failures. + +## Files Created/Modified + +| File | Action | +|------|--------| +| `src/skill.ts` | Create | +| `src/prompt.ts` | Create | +| `src/index.ts` | Append exports | +| `test/skill.test.ts` | Create | +| `test/prompt.test.ts` | Create | +| `examples/l2.ts` | Create | + +## Commit + +When all tests pass: + +``` +feat: add Skill and Prompt L2 constructs + +Co-authored-by: Kiro Agent <244629292+kiro-agent@users.noreply.github.com> +``` diff --git a/docs/tasks/l2-constructs.md b/docs/tasks/l2-constructs.md index 669da84..0335a85 100644 --- a/docs/tasks/l2-constructs.md +++ b/docs/tasks/l2-constructs.md @@ -191,13 +191,15 @@ export interface AgentProps { readonly prompt?: string; readonly model?: string; readonly tools?: (string | ToolConfig | ToolConfig[])[]; + readonly skills?: Skill[]; + readonly prompts?: Prompt[]; readonly mcpServers?: Record; readonly hooks?: HooksConfig; readonly resources?: (string | ResourceConfig)[]; } ``` -Note `tools` accepts `ToolConfig[]` too (from `BuiltInTool.all()`), which gets flattened. +Note `tools` accepts `ToolConfig[]` too (from `BuiltInTool.all()`), which gets flattened. `skills` and `prompts` accept L2 construct instances and wire them into the `resources` array as `skill://` and `file://` URIs respectively. ### Behavior @@ -207,9 +209,20 @@ Note `tools` accepts `ToolConfig[]` too (from `BuiltInTool.all()`), which gets f - `addMcpServer(name, config)` — adds to MCP servers map - `addHook(event, entry)` — appends to hooks for the given lifecycle event - `addResource(resource)` — appends to resources list +- `addSkill(skill: Skill)` — adds a `skill://` resource pointing to the skill's synthesized SKILL.md path. This is how Kiro CLI discovers skills for an agent: via `skill://` URIs in the `resources` array. The skill's output path is `skills/{skillName}/SKILL.md`, so this method adds `skill://skills/{skillName}/SKILL.md` to the resources list. +- `addPrompt(prompt: Prompt)` — adds a `file://` resource pointing to the prompt's synthesized markdown path (`prompts/{name}.md`). This loads the prompt content into the agent's context at startup. - All `addX()` methods return `this` for chaining - When the same tool is added twice, settings are deep-merged (via `lodash.merge` or manual spread) +### How Skills and Prompts Wire Into Agents + +Per the [Kiro CLI agent configuration reference](https://kiro.dev/docs/cli/custom-agents/configuration-reference/), skills are registered on agents via the `resources` field using URI schemes: + +- `skill://path/to/SKILL.md` — skill resources are progressively loaded: metadata (name, description) at startup, full content on demand +- `file://path/to/file.md` — file resources are loaded directly into context at startup + +When you call `agent.addSkill(skill)`, the Agent adds `skill://skills/{skillName}/SKILL.md` to its resources. When you call `agent.addPrompt(prompt)`, it adds `file://prompts/{promptName}.md`. + ### Example ```ts @@ -225,6 +238,18 @@ const agent = new Agent(app, 'dev', { ], }); +const skill = new Skill(app, 'typescript', { + description: 'TypeScript expertise', + instructions: '# TypeScript\n\nUse strict mode...', +}); + +const prompt = new Prompt(app, 'review', { + content: '# Code Review\n\nReview for correctness.', +}); + +agent.addSkill(skill); +agent.addPrompt(prompt); + agent.addMcpServer('github', { command: 'gh-mcp', env: { GITHUB_TOKEN: '${GITHUB_TOKEN}' }, @@ -238,7 +263,9 @@ agent.addMcpServer('github', { - [ ] `ToolConfig` objects correctly split into `tools`, `allowedTools`, `toolsSettings` - [ ] Tool settings deep-merge when same tool added twice - [ ] `ToolConfig[]` (from `BuiltInTool.all()`) flattened correctly -- [ ] Unit tests: basic synth, builder methods, tool decomposition, tool merging, empty agent +- [ ] `addSkill(skill)` adds `skill://skills/{name}/SKILL.md` to resources +- [ ] `addPrompt(prompt)` adds `file://prompts/{name}.md` to resources +- [ ] Unit tests: basic synth, builder methods, tool decomposition, tool merging, skill/prompt registration --- @@ -308,8 +335,9 @@ export interface PromptProps { } ``` -### Behavior +Implement `Prompt` class extending `Construct`: +- `readonly promptName: string` (public, set from `props.name ?? id`) — used by `Agent.addPrompt()` to build the `file://` resource URI - Wraps `CfgPrompt` — delegates synthesis entirely - `Prompt.fromFile()` static factory reads a markdown file from disk, parsing YAML frontmatter into metadata and body into content - Provides `appendContent()` method for post-construction content additions (uses `Lazy` internally) 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/tools.ts b/examples/tools.ts deleted file mode 100644 index 8691a1b..0000000 --- a/examples/tools.ts +++ /dev/null @@ -1,25 +0,0 @@ -/** - * Tools example: configure an agent with typed tool settings and shell permissions. - * - * Run: npx tsx examples/tools.ts - * Output: .kiro-constructs.out/agents/dev.json - */ - -import { App, Agent, BuiltInTool, Shell } from '@kiro/constructs'; - -const app = new App(); - -new Agent(app, 'dev', { - description: 'Development assistant with fine-grained tool permissions', - prompt: 'You are a helpful development assistant.', - tools: [ - BuiltInTool.all({ allowed: true }), - BuiltInTool.shell({ - allow: [Shell.git.readonly(), Shell.git.write(), Shell.npm.scripts(), Shell.files.inspect()], - deny: [Shell.git.destructive()], - }), - BuiltInTool.write({ deniedPaths: ['.env', '*.pem', '*.key'] }), - ], -}); - -app.synth().then(() => console.log('Synthesized to', app.outdir)); diff --git a/packages/kiro-constructs/src/agent.ts b/packages/kiro-constructs/src/agent.ts index 4197cf1..3176dc4 100644 --- a/packages/kiro-constructs/src/agent.ts +++ b/packages/kiro-constructs/src/agent.ts @@ -2,6 +2,8 @@ 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; @@ -9,6 +11,8 @@ export interface AgentProps { 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)[]; @@ -79,6 +83,14 @@ export class Agent extends Construct { 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, @@ -123,6 +135,16 @@ export class Agent extends Construct { 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)]; diff --git a/packages/kiro-constructs/src/index.ts b/packages/kiro-constructs/src/index.ts index 3295b83..8e06222 100644 --- a/packages/kiro-constructs/src/index.ts +++ b/packages/kiro-constructs/src/index.ts @@ -24,3 +24,5 @@ export { 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/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/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/test/agent.test.ts b/packages/kiro-constructs/test/agent.test.ts index 31e7384..2b4b007 100644 --- a/packages/kiro-constructs/test/agent.test.ts +++ b/packages/kiro-constructs/test/agent.test.ts @@ -2,7 +2,7 @@ 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, BuiltInTool, Shell } from '../src/index.js'; +import { App, Agent, Skill, Prompt, BuiltInTool, Shell } from '../src/index.js'; let tmpDir: string; let outdir: string; @@ -87,4 +87,17 @@ describe('Agent', () => { 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/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'); + }); +}); From 2e315987136e02fc215162988f0af223f606db27 Mon Sep 17 00:00:00 2001 From: Alex Bello <77049455+alxbello@users.noreply.github.com> Date: Thu, 23 Apr 2026 14:37:26 -0500 Subject: [PATCH 4/7] chore: remove docs/ from tracking --- .gitignore | 1 + docs/tasks/1-tool-config.md | 351 ----------------------------- docs/tasks/2-agent.md | 343 ---------------------------- docs/tasks/3-skill-prompt.md | 258 --------------------- docs/tasks/l2-constructs.md | 423 ----------------------------------- 5 files changed, 1 insertion(+), 1375 deletions(-) delete mode 100644 docs/tasks/1-tool-config.md delete mode 100644 docs/tasks/2-agent.md delete mode 100644 docs/tasks/3-skill-prompt.md delete mode 100644 docs/tasks/l2-constructs.md diff --git a/.gitignore b/.gitignore index 34e80c4..22bc764 100644 --- a/.gitignore +++ b/.gitignore @@ -4,5 +4,6 @@ coverage/ .kiro-constructs.out/ .kiro-constructs.cache/ *.tsbuildinfo +docs/ .idea/ diff --git a/docs/tasks/1-tool-config.md b/docs/tasks/1-tool-config.md deleted file mode 100644 index 84fe055..0000000 --- a/docs/tasks/1-tool-config.md +++ /dev/null @@ -1,351 +0,0 @@ -# Task 1: ToolConfig Interface, BuiltInTool Factories, and Shell Permissions - -Parent: [l2-constructs.md](./l2-constructs.md) — Task 1 - -## Context - -All source files live in `packages/kiro-constructs/src/`. Tests in `packages/kiro-constructs/test/`. - -Existing conventions: -- No license headers in source files (Apache-2.0 license at repo root is sufficient) -- ESM (`"type": "module"`, `.js` extensions in imports) -- Tests use `vitest` (`describe`, `it`, `expect`), temp dirs for synth tests -- Existing exports in `src/index.ts` — append new exports, don't reorder existing ones - -The `CfgAgent` L1 (in `src/l1/cfg-agent.ts`) already supports these fields that tools wire into: -- `tools: string[]` — list of tool names -- `allowedTools: string[]` — auto-approved tool names -- `toolsSettings: Record` — per-tool settings keyed by tool name - -## Steps - -### Step 1: Create `src/tools/tool-config.ts` - -Create the file with the Apache-2.0 header. Define and export: - -```ts -export interface ToolConfig { - readonly toolName: string; - readonly allowed?: boolean; - readonly settings?: Record; -} -``` - -### Step 2: Create `src/tools/shell-commands.ts` - -Create the file with the Apache-2.0 header. Implement regex builder helpers for safe shell command patterns. - -Define these constants (not exported): - -```ts -const SAFE_ARGS = '( [^;|&`$]+)?'; -const SAFE_PIPE = '( \\| (tail|head)( -[0-9n]+)?| \\| grep( [^;|&`$]+)?)?'; -const STDERR_REDIRECT = '( 2>(&1|/dev/null))?'; -const CD_PREFIX = '(cd [^ ]+ && )?'; -``` - -Implement and export: - -```ts -export function buildPattern(cmd: string, subcommands: string[]): string -// Returns: `^${CD_PREFIX}(${cmd} (${cmds_joined_by_pipe}))${SAFE_ARGS}${STDERR_REDIRECT}${SAFE_PIPE}$` - -export function git(...subcommands: string[]): string -// Calls buildPattern('git', subcommands) - -export function npm(...subcommands: string[]): string -// Calls buildPattern('npm', subcommands) - -export function fileOps(...commands: string[]): string -// Different pattern: `^${CD_PREFIX}(${cmds_joined_by_pipe})${SAFE_ARGS}${STDERR_REDIRECT}${SAFE_PIPE}$` -// Note: fileOps treats each command as a top-level command, not a subcommand -``` - -### Step 3: Create `src/tools/shell.ts` - -Create the file with the Apache-2.0 header. Import `git`, `npm`, `fileOps` from `./shell-commands.js`. - -Define and export: - -```ts -export interface IShellPermission { - readonly patterns: string[]; -} -``` - -Implement `Shell` class with private constructor and static members: - -```ts -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() {} -} -``` - -### Step 4: Create `src/tools/built-in-tools.ts` - -Create the file with the Apache-2.0 header. Import `ToolConfig` from `./tool-config.js` and `IShellPermission` from `./shell.js`. - -Define and export these prop interfaces: - -```ts -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; -} -``` - -Implement `BuiltInTool` class with private constructor: - -- `static shell(props: ShellToolProps = {}): ToolConfig` — Flatten `props.allow` into `settings.allowedCommands` (flatMap `.patterns`). Flatten `props.deny` into `settings.deniedCommands`. Include `autoAllowReadonly` and `denyByDefault` in settings if defined. Return `{ toolName: 'shell', allowed?, settings? }`. Only include `allowed` key if `props.allowed !== undefined`. Only include `settings` key if it has entries. - -- `static read(props: PathToolProps = {}): ToolConfig` — Delegate to private `pathTool('read', props)`. - -- `static write(props: PathToolProps = {}): ToolConfig` — Delegate to private `pathTool('write', props)`. - -- `static glob(props: PathToolWithReadOnlyProps = {}): ToolConfig` — Delegate to private `pathTool('glob', props)`. - -- `static grep(props: PathToolWithReadOnlyProps = {}): ToolConfig` — Delegate to private `pathTool('grep', props)`. - -- `static aws(props: BuiltInToolProps = {}): ToolConfig` — Return `{ toolName: 'aws', allowed? }`. - -- `static webFetch(props: BuiltInToolProps = {}): ToolConfig` — Return `{ toolName: 'web_fetch', allowed? }`. - -- `static webSearch(props: BuiltInToolProps = {}): ToolConfig` — Return `{ toolName: 'web_search', allowed? }`. - -- `static code(props: BuiltInToolProps = {}): ToolConfig` — Return `{ toolName: 'code', allowed? }`. - -- `static all(props: AllToolsProps = {}): ToolConfig[]` — Return array of all 9 tools. For each tool, cascade `props.allowed` as the default `allowed` value, but let per-tool `allowed` override. Example: `BuiltInTool.shell({ ...props.shell, allowed: props.shell?.allowed ?? props.allowed })`. - -- `private static pathTool(toolName: string, props: PathToolWithReadOnlyProps): ToolConfig` — Build settings from `allowedPaths`, `deniedPaths`, `allowReadOnly` (only include keys that are defined and non-empty). Return `{ toolName, allowed?, settings? }`. - -### Step 5: Create `src/tools/index.ts` - -Create the barrel file with the Apache-2.0 header: - -```ts -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'; -``` - -### Step 6: Update `src/index.ts` - -Append these exports at the end of the existing `src/index.ts` (do NOT modify existing exports): - -```ts -export { - type ToolConfig, - BuiltInTool, - type AllToolsProps, - type BuiltInToolProps, - type ShellToolProps, - type PathToolProps, - type PathToolWithReadOnlyProps, - Shell, - type IShellPermission, -} from './tools/index.js'; -``` - -### Step 7: Create `test/tools.test.ts` - -Create the test file with the Apache-2.0 header. Import from `../src/index.js`. Use `describe`/`it`/`expect` from `vitest`. - -Write these test cases: - -``` -describe('ToolConfig and BuiltInTool', () => { - - describe('BuiltInTool.shell', () => { - it('returns toolName shell with no settings when called with no args') - → expect(BuiltInTool.shell()).toEqual({ toolName: 'shell' }) - - it('includes allowed when specified') - → expect(BuiltInTool.shell({ allowed: true })).toEqual({ toolName: 'shell', allowed: true }) - - it('maps allow permissions to allowedCommands in settings') - → result = BuiltInTool.shell({ allow: [Shell.git.readonly()] }) - → expect(result.toolName).toBe('shell') - → expect(result.settings?.allowedCommands).toBeInstanceOf(Array) - → expect((result.settings?.allowedCommands as string[]).length).toBe(1) - → expect((result.settings?.allowedCommands as string[])[0]).toMatch(/^.*git.*status.*$/) - - it('maps deny permissions to deniedCommands in settings') - → result = BuiltInTool.shell({ deny: [Shell.git.destructive()] }) - → expect(result.settings?.deniedCommands).toBeInstanceOf(Array) - - it('includes autoAllowReadonly and denyByDefault in settings') - → result = BuiltInTool.shell({ autoAllowReadonly: true, denyByDefault: true }) - → expect(result.settings).toEqual({ autoAllowReadonly: true, denyByDefault: true }) - - it('combines multiple permissions by flattening patterns') - → result = BuiltInTool.shell({ allow: [Shell.git.readonly(), Shell.npm.scripts()] }) - → expect((result.settings?.allowedCommands as string[]).length).toBe(2) - }) - - describe('BuiltInTool path tools', () => { - it('read returns toolName read') - → expect(BuiltInTool.read()).toEqual({ toolName: 'read' }) - - it('write includes path settings') - → result = BuiltInTool.write({ deniedPaths: ['.env'] }) - → expect(result).toEqual({ toolName: 'write', settings: { deniedPaths: ['.env'] } }) - - it('glob includes allowReadOnly') - → result = BuiltInTool.glob({ allowReadOnly: true }) - → expect(result).toEqual({ toolName: 'glob', settings: { allowReadOnly: true } }) - - it('grep includes allowedPaths and deniedPaths') - → result = BuiltInTool.grep({ allowedPaths: ['/src'], deniedPaths: ['/dist'] }) - → expect(result.settings).toEqual({ allowedPaths: ['/src'], deniedPaths: ['/dist'] }) - }) - - describe('BuiltInTool simple tools', () => { - it('aws returns toolName aws') - → expect(BuiltInTool.aws()).toEqual({ toolName: 'aws' }) - - it('webFetch uses toolName web_fetch') - → expect(BuiltInTool.webFetch()).toEqual({ toolName: 'web_fetch' }) - - it('webSearch uses toolName web_search') - → expect(BuiltInTool.webSearch()).toEqual({ toolName: 'web_search' }) - - it('code returns toolName code') - → expect(BuiltInTool.code()).toEqual({ toolName: 'code' }) - }) - - describe('BuiltInTool.all', () => { - it('returns exactly 9 tools') - → expect(BuiltInTool.all()).toHaveLength(9) - - it('returns all expected tool names') - → names = BuiltInTool.all().map(t => t.toolName) - → expect(names).toEqual(['shell', 'read', 'write', 'glob', 'grep', 'aws', 'web_fetch', 'web_search', 'code']) - - it('cascades allowed to all tools') - → tools = BuiltInTool.all({ allowed: true }) - → tools.forEach(t => expect(t.allowed).toBe(true)) - - it('allows per-tool override of allowed') - → tools = BuiltInTool.all({ allowed: true, shell: { allowed: false } }) - → shell = tools.find(t => t.toolName === 'shell')! - → expect(shell.allowed).toBe(false) - → rest = tools.filter(t => t.toolName !== 'shell') - → rest.forEach(t => expect(t.allowed).toBe(true)) - - it('passes per-tool settings through') - → tools = BuiltInTool.all({ write: { deniedPaths: ['.env'] } }) - → write = tools.find(t => t.toolName === 'write')! - → expect(write.settings).toEqual({ deniedPaths: ['.env'] }) - }) - - describe('Shell', () => { - it('Shell.command returns custom pattern') - → expect(Shell.command('my-cmd.*').patterns).toEqual(['my-cmd.*']) - - it('Shell.git.readonly returns patterns matching git read commands') - → patterns = Shell.git.readonly().patterns - → expect(patterns.length).toBe(1) - → expect(patterns[0]).toContain('git') - → expect(patterns[0]).toContain('status') - - it('Shell.npm.scripts returns patterns matching npm commands') - → patterns = Shell.npm.scripts().patterns - → expect(patterns[0]).toContain('npm') - → expect(patterns[0]).toContain('run') - - it('Shell.files.inspect returns patterns matching file commands') - → patterns = Shell.files.inspect().patterns - → expect(patterns[0]).toContain('ls') - → expect(patterns[0]).toContain('cat') - }) -}) -``` - -### Step 8: Build and test - -Run from the repo root: - -```bash -cd packages/kiro-constructs && npm run build && npm test -``` - -Fix any type errors or test failures. All existing tests must continue to pass. - -## Files Created/Modified - -| File | Action | -|------|--------| -| `src/tools/tool-config.ts` | Create | -| `src/tools/shell-commands.ts` | Create | -| `src/tools/shell.ts` | Create | -| `src/tools/built-in-tools.ts` | Create | -| `src/tools/index.ts` | Create | -| `src/index.ts` | Append exports | -| `test/tools.test.ts` | Create | - -## Commit - -When all tests pass, commit with: - -``` -feat: add ToolConfig interface, BuiltInTool factories, and Shell permissions - -Co-authored-by: Kiro Agent <244629292+kiro-agent@users.noreply.github.com> -``` diff --git a/docs/tasks/2-agent.md b/docs/tasks/2-agent.md deleted file mode 100644 index 2f75b72..0000000 --- a/docs/tasks/2-agent.md +++ /dev/null @@ -1,343 +0,0 @@ -# Task 2: Agent L2 Construct - -Parent: [l2-constructs.md](./l2-constructs.md) — Task 2 - -## Context - -All source files live in `packages/kiro-constructs/src/`. Tests in `packages/kiro-constructs/test/`. - -Conventions: -- No license headers in source files (Apache-2.0 at repo root) -- ESM (`"type": "module"`, `.js` extensions in imports) -- Tests use `vitest` (`describe`, `it`, `expect`), temp dirs for synth tests -- Existing exports in `src/index.ts` — append new exports, don't reorder existing ones -- Build and test from repo root: `npm run build && npm test` - -The `Agent` L2 wraps the `CfgAgent` L1 and provides builder-style composition. The key feature: it accepts `ToolConfig` objects (from Task 1) and automatically decomposes them into the L1's `tools`, `allowedTools`, and `toolsSettings` fields. - -### Key existing types to use - -`CfgAgent` L1 (in `src/l1/cfg-agent.ts`) accepts: -- `tools: string[]` — tool name strings -- `allowedTools: string[]` — auto-approved tool names -- `toolsSettings: Record` — per-tool settings keyed by name -- `mcpServers: Record` — MCP server configs -- `hooks: CfgAgent.HooksProperty` — lifecycle hooks (agentSpawn, userPromptSubmit, preToolUse, postToolUse, stop) -- `resources: (string | CfgAgent.ResourceProperty)[]` — knowledge base resources -- `description`, `prompt`, `model`, `name`, `toolAliases`, `includeMcpJson`, `keyboardShortcut`, `welcomeMessage` - -`Lazy.any(() => value)` (in `src/lazy.ts`) — defers evaluation until `resolve()` is called during synthesis. Use this for any field that can be mutated after construction via `addX()`. - -`ToolConfig` (in `src/tools/tool-config.ts`): -- `toolName: string` → goes into `tools[]` -- `allowed?: boolean` → if `true`, goes into `allowedTools[]` -- `settings?: Record` → goes into `toolsSettings[toolName]`, deep-merged if same tool added twice - -## Steps - -### Step 1: Create `src/agent.ts` - -Create the file. Import from: -- `constructs` → `Construct` -- `./l1/cfg-agent.js` → `CfgAgent` -- `./lazy.js` → `Lazy` -- `./tools/tool-config.js` → `ToolConfig` - -Define and export the `AgentProps` interface: - -```ts -export interface AgentProps { - readonly name?: string; - readonly description: string; - readonly prompt?: string; - readonly model?: string; - readonly tools?: (string | ToolConfig | ToolConfig[])[]; - readonly mcpServers?: Record; - readonly hooks?: CfgAgent.HooksProperty; - readonly resources?: (string | CfgAgent.ResourceProperty)[]; - readonly toolAliases?: Record; - readonly includeMcpJson?: boolean; - readonly keyboardShortcut?: string; - readonly welcomeMessage?: string; -} -``` - -Implement the `Agent` class extending `Construct`: - -**Private state** — mutable arrays/maps that `addX()` methods push into: -```ts -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** — takes `(scope: Construct, id: string, props: AgentProps)`: -1. Call `super(scope, id)` -2. Set `this.agentName = props.name ?? id` -3. Flatten and push initial `props.tools` into `this._tools` (flatten any nested arrays) -4. Copy initial `props.mcpServers` entries into `this._mcpServers` -5. Copy initial `props.hooks` entries into `this._hooks` -6. Copy initial `props.resources` into `this._resources` -7. Create the L1 internally: - -```ts -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()), -}); -``` - -**Builder methods** — all return `this`: - -- `addTool(tool: string | ToolConfig | ToolConfig[]): this` — if array, flatten and push each; otherwise push directly into `this._tools` -- `addMcpServer(name: string, config: CfgAgent.McpServerProperty): this` — set into `this._mcpServers` -- `addHook(event: string, hook: CfgAgent.HookProperty): this` — append to `this._hooks` for the given event key -- `addResource(resource: string | CfgAgent.ResourceProperty): this` — push into `this._resources` - -**Private render methods** — called lazily during synthesis, return `undefined` when empty (so the key is omitted from JSON): - -- `renderTools(): string[] | undefined` — iterate `this._tools`, extract `toolName` from `ToolConfig` objects, strings pass through. Deduplicate. Return `undefined` if empty. - -- `renderAllowedTools(): string[] | undefined` — iterate `this._tools`, collect `toolName` where `allowed === true`. Deduplicate. Return `undefined` if empty. - -- `renderToolsSettings(): Record | undefined` — iterate `this._tools`, for each `ToolConfig` with `settings`, deep-merge into a map keyed by `toolName`. For deep merge, use a simple recursive merge function (see below). Return `undefined` if empty. - -- `renderMcpServers(): Record | undefined` — convert `this._mcpServers` to object. Return `undefined` if empty. - -- `renderHooks(): CfgAgent.HooksProperty | undefined` — convert `this._hooks` to object. Return `undefined` if empty. - -- `renderResources(): (string | CfgAgent.ResourceProperty)[] | undefined` — return `this._resources` or `undefined` if empty. - -**Deep merge helper** — implement a private static method or standalone function for merging tool settings. Do NOT add `lodash.merge` as a dependency. Instead write a minimal recursive merge: - -```ts -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 (isPlainObject(tVal) && isPlainObject(sVal)) { - result[key] = deepMerge(tVal as Record, sVal as Record); - } else { - result[key] = sVal; - } - } - return result; -} - -function isPlainObject(v: unknown): v is Record { - return typeof v === 'object' && v !== null && !Array.isArray(v); -} -``` - -### Step 2: Update `src/index.ts` - -Append at the end: - -```ts -export { Agent, type AgentProps } from './agent.js'; -``` - -### Step 3: Create `test/agent.test.ts` - -Import from `../src/index.js`: `App`, `Agent`, `BuiltInTool`, `Shell`, `CfgAgent`. -Import `fs`, `os`, `path` from node. -Import `describe`, `it`, `expect`, `beforeEach`, `afterEach` from `vitest`. - -Use the same temp dir pattern as existing tests: - -```ts -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 }); -}); -``` - -Helper to synth and read the agent JSON: - -```ts -async function synthAgent(app: App, name: string) { - await app.synth(); - return JSON.parse(fs.readFileSync(path.join(outdir, 'agents', `${name}.json`), 'utf-8')); -} -``` - -Write these test cases: - -``` -describe('Agent', () => { - - it('synthesizes basic agent with description and prompt') - → new Agent(app, 'dev', { description: 'Dev', prompt: 'You are helpful.' }) - → synth, read agents/dev.json - → expect config.description === 'Dev' - → expect config.prompt === 'You are helpful.' - → expect config.tools to be undefined (no tools added) - - it('accepts string tools') - → new Agent(app, 'dev', { description: 'Dev', tools: ['read', 'write'] }) - → expect config.tools === ['read', 'write'] - → expect config.allowedTools to be undefined - → expect config.toolsSettings to be undefined - - it('decomposes ToolConfig into tools, allowedTools, and toolsSettings') - → new Agent(app, 'dev', { - description: 'Dev', - tools: [ - BuiltInTool.shell({ allowed: true, denyByDefault: true }), - BuiltInTool.write({ deniedPaths: ['.env'] }), - 'read', - ], - }) - → expect config.tools to contain 'shell', 'write', 'read' - → expect config.allowedTools === ['shell'] - → expect config.toolsSettings.shell to deep equal { denyByDefault: true } - → expect config.toolsSettings.write to deep equal { deniedPaths: ['.env'] } - - it('flattens ToolConfig[] from BuiltInTool.all()') - → new Agent(app, 'dev', { description: 'Dev', tools: [BuiltInTool.all()] }) - → expect config.tools to have length 9 - → expect config.tools to contain 'shell', 'read', 'write', 'glob', 'grep', 'aws', 'web_fetch', 'web_search', 'code' - - it('deep-merges settings when same tool added twice') - → const agent = new Agent(app, 'dev', { - description: 'Dev', - tools: [BuiltInTool.shell({ allow: [Shell.git.readonly()] })], - }) - → agent.addTool(BuiltInTool.shell({ deny: [Shell.git.destructive()] })) - → synth - → expect config.toolsSettings.shell to have both allowedCommands and deniedCommands - - it('addTool works after construction') - → const agent = new Agent(app, 'dev', { description: 'Dev' }) - → agent.addTool('shell') - → agent.addTool(BuiltInTool.write({ allowed: true })) - → expect config.tools === ['shell', 'write'] - → expect config.allowedTools === ['write'] - - it('addMcpServer works after construction') - → const agent = new Agent(app, 'dev', { description: 'Dev' }) - → agent.addMcpServer('github', { command: 'gh-mcp', args: ['--token', 'xxx'] }) - → expect config.mcpServers.github === { command: 'gh-mcp', args: ['--token', 'xxx'] } - - it('addHook works after construction') - → const agent = new Agent(app, 'dev', { description: 'Dev' }) - → agent.addHook('agentSpawn', { command: 'node setup.js' }) - → agent.addHook('agentSpawn', { command: 'node warmup.js' }) - → expect config.hooks.agentSpawn to have length 2 - - it('addResource works after construction') - → const agent = new Agent(app, 'dev', { description: 'Dev' }) - → agent.addResource('/docs') - → agent.addResource({ type: 'knowledgeBase', source: '/kb', name: 'docs' }) - → expect config.resources to have length 2 - - it('builder methods return this for chaining') - → const agent = new Agent(app, 'dev', { description: 'Dev' }) - → const result = agent.addTool('shell').addMcpServer('x', { command: 'x' }).addHook('stop', { command: 'y' }).addResource('/z') - → expect result to be agent (same reference) - - it('omits empty fields from output') - → new Agent(app, 'dev', { description: 'Dev' }) - → synth - → expect config to NOT have keys: tools, allowedTools, toolsSettings, mcpServers, hooks, resources - - it('deduplicates tool names') - → new Agent(app, 'dev', { description: 'Dev', tools: ['shell', 'shell', BuiltInTool.shell()] }) - → expect config.tools === ['shell'] (deduplicated) - - it('passes through model, keyboardShortcut, welcomeMessage') - → new Agent(app, 'dev', { - description: 'Dev', - model: 'claude-sonnet', - keyboardShortcut: 'ctrl+shift+d', - welcomeMessage: 'Hello!', - }) - → expect config.model === 'claude-sonnet' - → expect config.keyboardShortcut === 'ctrl+shift+d' - → expect config.welcomeMessage === 'Hello!' - -) -``` - -### Step 4: Update `examples/tools.ts` - -Replace the manual decomposition with the Agent L2. The new example should be: - -```ts -/** - * Tools example: configure an agent with typed tool settings and shell permissions. - * - * Run: npx tsx examples/tools.ts - * Output: .kiro-constructs.out/agents/dev.json - */ - -import { App, Agent, BuiltInTool, Shell } from '@kiro/constructs'; - -const app = new App(); - -new Agent(app, 'dev', { - description: 'Development assistant with fine-grained tool permissions', - prompt: 'You are a helpful development assistant.', - tools: [ - BuiltInTool.all({ allowed: true }), - BuiltInTool.shell({ - allow: [Shell.git.readonly(), Shell.git.write(), Shell.npm.scripts(), Shell.files.inspect()], - deny: [Shell.git.destructive()], - }), - BuiltInTool.write({ deniedPaths: ['.env', '*.pem', '*.key'] }), - ], -}); - -app.synth().then(() => console.log('Synthesized to', app.outdir)); -``` - -### Step 5: Build and test - -Run from the repo root: - -```bash -npm run build && npm test -``` - -Fix any type errors or test failures. All existing tests must continue to pass. - -## Files Created/Modified - -| File | Action | -|------|--------| -| `src/agent.ts` | Create | -| `src/index.ts` | Append export | -| `test/agent.test.ts` | Create | -| `examples/tools.ts` | Replace contents | - -## Commit - -When all tests pass, commit with: - -``` -feat: add Agent L2 construct with ToolConfig decomposition - -Co-authored-by: Kiro Agent <244629292+kiro-agent@users.noreply.github.com> -``` diff --git a/docs/tasks/3-skill-prompt.md b/docs/tasks/3-skill-prompt.md deleted file mode 100644 index 2313371..0000000 --- a/docs/tasks/3-skill-prompt.md +++ /dev/null @@ -1,258 +0,0 @@ -# Task 3+4: Skill L2, Prompt L2, and L2 Example - -Parent: [l2-constructs.md](./l2-constructs.md) — Tasks 3, 4, 5 - -## Context - -All source files live in `packages/kiro-constructs/src/`. Tests in `packages/kiro-constructs/test/`. - -Conventions: -- No license headers in source files (Apache-2.0 at repo root) -- ESM (`"type": "module"`, `.js` extensions in imports) -- Tests use `vitest` (`describe`, `it`, `expect`), temp dirs for synth tests -- Build and test from repo root: `npm run build && npm test` -- `gray-matter` is already a dependency — use it for frontmatter parsing -- Keep tests focused — one test per distinct behavior, combine related assertions - -## Steps - -### Step 1: Create `src/skill.ts` - -Import from: -- `constructs` → `Construct` -- `./synthesis/assembly.js` → `IAssembly` -- `./synthesis/synthesizable.js` → `ISynthesizable` -- `./synthesis/source.js` → `Source` -- `gray-matter` → `matter` -- `node:fs` and `node:path` - -Define and export: - -```ts -export interface SkillProps { - readonly name?: string; - readonly description: string; - readonly instructions: string; - readonly assets?: Record; - readonly metadata?: Record; -} -``` - -Implement `Skill` class extending `Construct` implementing `ISynthesizable`: - -- `readonly skillName: string` (public, set from `props.name ?? id`) -- Store `description`, `instructions`, `assets`, `metadata` from props - -**`synthesize(assembly: IAssembly)`:** -1. Create a sub-assembly: `assembly.subAssembly('skills').subAssembly(this.skillName)` -2. Build frontmatter object: `{ name: this.skillName, description: this.description, ...this.metadata }` -3. Use `matter.stringify('\n' + this.instructions, frontmatter)` to produce the SKILL.md content -4. Write `SKILL.md` via `Source.text()` -5. If `assets` is defined, write each entry as `Source.text(value)` to the skill sub-assembly - -**`static fromDirectory(scope: Construct, id: string, dirPath: string): Skill`:** -1. Resolve `dirPath` relative to `App.sourceDir` using `App.of(scope).sourceDir` -2. Read `SKILL.md` from the directory using `fs.readFileSync` -3. Parse with `matter()` to extract frontmatter and content -4. Return `new Skill(scope, id, { name: frontmatter.name ?? id, description: frontmatter.description, instructions: content.trim(), metadata: remaining frontmatter fields })` - -### Step 2: Create `src/prompt.ts` - -Import from: -- `constructs` → `Construct` -- `./l1/cfg-prompt.js` → `CfgPrompt` -- `./lazy.js` → `Lazy` -- `gray-matter` → `matter` -- `node:fs` and `node:path` - -Define and export: - -```ts -export interface PromptProps { - readonly name?: string; - readonly content: string; - readonly metadata?: Record; -} -``` - -Implement `Prompt` class extending `Construct`: - -- Store `_content: string` and `_appendedContent: string[]` (for `appendContent`) -- In constructor, create the L1 internally: - -```ts -new CfgPrompt(this, 'Resource', { - name: props.name ?? id, - content: Lazy.any(() => this.renderContent()), - metadata: props.metadata, -}); -``` - -**`appendContent(content: string): this`** — push to `_appendedContent`, return `this` - -**`private renderContent(): string`** — return `this._content` + joined appended content (separated by `\n\n`) - -**`static fromFile(scope: Construct, id: string, filePath: string): Prompt`:** -1. Resolve `filePath` relative to `App.of(scope).sourceDir` -2. Read file with `fs.readFileSync` -3. Parse with `matter()` to extract frontmatter and content -4. Return `new Prompt(scope, id, { name: frontmatter.name ?? id, content: content.trim(), metadata: remaining frontmatter fields })` - -### Step 3: Update `src/index.ts` - -Append at the end: - -```ts -export { Skill, type SkillProps } from './skill.js'; -export { Prompt, type PromptProps } from './prompt.js'; -``` - -### Step 4: Create `test/skill.test.ts` - -Write these test cases: - -``` -describe('Skill', () => { - - it('synthesizes SKILL.md with frontmatter and instructions') - → new Skill(app, 'typescript', { description: 'TS expertise', instructions: '# TypeScript\n\nUse strict mode.' }) - → synth, read skills/typescript/SKILL.md - → parse with matter() - → expect data.name === 'typescript' - → expect data.description === 'TS expertise' - → expect content.trim() to contain '# TypeScript' - - it('writes assets to skill subdirectory') - → new Skill(app, 'ts', { description: 'TS', instructions: 'instructions', assets: { 'example.json': '{}' } }) - → synth - → expect skills/ts/example.json to exist and equal '{}' - - it('fromDirectory reads existing SKILL.md') - → write a SKILL.md with frontmatter to a temp dir - → Skill.fromDirectory(app, 'loaded', tempSkillDir) - → synth, read the output SKILL.md - → expect frontmatter and content match what was written - -) -``` - -### Step 5: Create `test/prompt.test.ts` - -Write these test cases: - -``` -describe('Prompt', () => { - - it('synthesizes identically to CfgPrompt') - → new Prompt(app, 'review', { content: '# Review\n\nCheck code.' }) - → synth, read prompts/review.md - → expect content === '# Review\n\nCheck code.' - - it('appendContent adds content after construction') - → const p = new Prompt(app, 'review', { content: '# Review' }) - → p.appendContent('## Section 2') - → synth, read prompts/review.md - → expect content to contain both '# Review' and '## Section 2' - - it('fromFile reads existing markdown with frontmatter') - → write a markdown file with frontmatter to temp dir - → Prompt.fromFile(app, 'loaded', tempFilePath) - → synth, read the output - → expect content and metadata match - -) -``` - -### Step 6: Create `examples/l2.ts` - -Create a single example showing all L2 constructs together: - -```ts -/** - * L2 constructs example: Agent, Skill, and Prompt working together. - * - * 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(); - -new Agent(app, 'dev', { - description: 'Full-stack development assistant', - prompt: 'You are a senior full-stack developer.', - 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'] }), - ], -}); - -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'), -}); - -new Prompt(app, 'review', { - content: [ - '# Code Review', - '', - 'Review the code for:', - '- Correctness and edge cases', - '- Performance implications', - '- Readability and naming', - ].join('\n'), -}); - -app.synth().then(() => console.log('Synthesized to', app.outdir)); -``` - -### Step 7: Build and test - -Run from the repo root: - -```bash -npm run build && npm test -``` - -Also run the example to verify output: - -```bash -npx tsx examples/l2.ts -``` - -Fix any type errors or test failures. - -## Files Created/Modified - -| File | Action | -|------|--------| -| `src/skill.ts` | Create | -| `src/prompt.ts` | Create | -| `src/index.ts` | Append exports | -| `test/skill.test.ts` | Create | -| `test/prompt.test.ts` | Create | -| `examples/l2.ts` | Create | - -## Commit - -When all tests pass: - -``` -feat: add Skill and Prompt L2 constructs - -Co-authored-by: Kiro Agent <244629292+kiro-agent@users.noreply.github.com> -``` diff --git a/docs/tasks/l2-constructs.md b/docs/tasks/l2-constructs.md deleted file mode 100644 index 0335a85..0000000 --- a/docs/tasks/l2-constructs.md +++ /dev/null @@ -1,423 +0,0 @@ -# L2 Constructs for @kiro/constructs - -## Overview - -Add an opinionated L2 layer on top of the existing L1 constructs (`CfgAgent`, `CfgPrompt`). L2s provide higher-level abstractions with builder-style APIs, sensible defaults, and composition patterns that make agent configuration significantly easier than hand-writing JSON. - -The existing L1s map 1:1 to output files. L2s encode opinions: they manage relationships between agents, skills, and prompts; they use `Lazy` for deferred rendering; and they provide `addX()` methods for incremental composition. - -## Current State - -The repo ships two L1 constructs: - -- `CfgAgent` — synthesizes to `agents/{name}.json` with full config passthrough -- `CfgPrompt` — synthesizes to `prompts/{name}.md` with optional YAML frontmatter - -Plus core infrastructure: `App`, `Assembly`, `Source`, `Lazy`, `Cache`, `Logger`, model providers. - -## What We're Building - -Three L2 construct families — **Agent**, **Skill**, **Prompt** — plus a **Tool** abstraction that lets L2 tool objects wire themselves into agents automatically. - ---- - -## Task 1: `ToolConfig` Interface and `BuiltInTool` Factories - -**Files:** `src/tools/tool-config.ts`, `src/tools/built-in-tools.ts`, `src/tools/shell.ts`, `src/tools/shell-commands.ts` - -The core idea: tools are objects that carry their own name, auto-approval flag, and settings. When you pass a tool to an `Agent`, the agent splits it into the right L1 fields (`tools`, `allowedTools`, `toolsSettings`). - -### ToolConfig Interface - -```ts -export interface ToolConfig { - readonly toolName: string; - readonly allowed?: boolean; - readonly settings?: Record; -} -``` - -This is the contract. Any L2 tool factory returns a `ToolConfig` (or `ToolConfig[]`). The `Agent` L2 accepts `(string | ToolConfig)[]` — strings are bare tool names, `ToolConfig` objects carry settings. - -### How Agent Wires Tools - -When `Agent` receives tools, it decomposes each `ToolConfig` into three L1 fields: - -| ToolConfig field | L1 CfgAgent field | Behavior | -|---|---|---| -| `toolName` | `tools[]` | Always added to the tools list | -| `allowed: true` | `allowedTools[]` | Added to auto-approved list | -| `settings` | `toolsSettings[toolName]` | Merged (deep) if same tool added twice | - -This means you can do: - -```ts -new Agent(app, 'dev', { - tools: [ - 'read', // bare string → just adds to tools[] - BuiltInTool.shell({ allowed: true }), // → tools: ['shell'], allowedTools: ['shell'] - BuiltInTool.write({ deniedPaths: ['.env'] }), // → tools: ['write'], toolsSettings: { write: { deniedPaths: ['.env'] } } - ], -}); -``` - -### BuiltInTool Factories - -Static factory methods that return `ToolConfig` with the correct tool name and settings: - -```ts -export class BuiltInTool { - static shell(props?: ShellToolProps): ToolConfig; - static read(props?: PathToolProps): ToolConfig; - static write(props?: PathToolProps): ToolConfig; - static glob(props?: PathToolWithReadOnlyProps): ToolConfig; - static grep(props?: PathToolWithReadOnlyProps): ToolConfig; - static aws(props?: BuiltInToolProps): ToolConfig; - static webFetch(props?: BuiltInToolProps): ToolConfig; - static webSearch(props?: BuiltInToolProps): ToolConfig; - static code(props?: BuiltInToolProps): ToolConfig; - static all(props?: AllToolsProps): ToolConfig[]; -} -``` - -Props per tool type: - -```ts -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; -} -``` - -`ShellToolProps.allow` and `.deny` accept `IShellPermission` objects — these are typed shell permission providers that expand to regex patterns in `toolsSettings.shell.allowedCommands` / `deniedCommands`. - -### Shell Permissions - -```ts -export interface IShellPermission { - readonly patterns: string[]; -} - -export class Shell { - static readonly git: { - readonly(): IShellPermission; - write(): IShellPermission; - destructive(): IShellPermission; - }; - static readonly npm: { - scripts(): IShellPermission; - }; - static readonly files: { - inspect(): IShellPermission; - }; - static command(pattern: string): IShellPermission; -} -``` - -Usage: - -```ts -BuiltInTool.shell({ - allowed: true, - allow: [Shell.git.readonly(), Shell.npm.scripts()], - deny: [Shell.git.destructive()], -}) -``` - -This produces: - -```json -{ - "tools": ["shell"], - "allowedTools": ["shell"], - "toolsSettings": { - "shell": { - "allowedCommands": ["^(git (status|log|diff|...))...$", "^(npm (run|test|...))...$"], - "deniedCommands": ["^(git (push --force|reset --hard|...))...$"] - } - } -} -``` - -### `BuiltInTool.all()` — Convenience Bundle - -Returns all 9 built-in tools. Per-tool overrides via named props: - -```ts -BuiltInTool.all({ - allowed: true, // default for all - shell: { allow: [Shell.git.readonly()] }, - write: { deniedPaths: ['.env', '*.pem'] }, -}) -``` - -### Acceptance Criteria - -- [ ] `ToolConfig` interface exported -- [ ] `BuiltInTool` static factories produce correct `ToolConfig` objects -- [ ] `Shell` permission helpers produce correct regex patterns -- [ ] `BuiltInTool.all()` returns 9 tools with per-tool overrides -- [ ] Unit tests: each factory, shell permissions, `all()` with overrides - ---- - -## Task 2: `Agent` L2 Construct - -**File:** `src/agent.ts` - -A higher-level agent construct that wraps `CfgAgent` and provides builder-style composition. The key feature: it accepts `ToolConfig` objects and decomposes them into the right L1 fields. - -### Interface - -```ts -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?: HooksConfig; - readonly resources?: (string | ResourceConfig)[]; -} -``` - -Note `tools` accepts `ToolConfig[]` too (from `BuiltInTool.all()`), which gets flattened. `skills` and `prompts` accept L2 construct instances and wire them into the `resources` array as `skill://` and `file://` URIs respectively. - -### Behavior - -- Implements `ISynthesizable` — creates a `CfgAgent` L1 internally during `synthesize()` -- Uses `Lazy` to defer rendering so `addX()` calls work after construction -- `addTool(tool: string | ToolConfig | ToolConfig[])` — flattens arrays, splits into tools/allowedTools/toolsSettings -- `addMcpServer(name, config)` — adds to MCP servers map -- `addHook(event, entry)` — appends to hooks for the given lifecycle event -- `addResource(resource)` — appends to resources list -- `addSkill(skill: Skill)` — adds a `skill://` resource pointing to the skill's synthesized SKILL.md path. This is how Kiro CLI discovers skills for an agent: via `skill://` URIs in the `resources` array. The skill's output path is `skills/{skillName}/SKILL.md`, so this method adds `skill://skills/{skillName}/SKILL.md` to the resources list. -- `addPrompt(prompt: Prompt)` — adds a `file://` resource pointing to the prompt's synthesized markdown path (`prompts/{name}.md`). This loads the prompt content into the agent's context at startup. -- All `addX()` methods return `this` for chaining -- When the same tool is added twice, settings are deep-merged (via `lodash.merge` or manual spread) - -### How Skills and Prompts Wire Into Agents - -Per the [Kiro CLI agent configuration reference](https://kiro.dev/docs/cli/custom-agents/configuration-reference/), skills are registered on agents via the `resources` field using URI schemes: - -- `skill://path/to/SKILL.md` — skill resources are progressively loaded: metadata (name, description) at startup, full content on demand -- `file://path/to/file.md` — file resources are loaded directly into context at startup - -When you call `agent.addSkill(skill)`, the Agent adds `skill://skills/{skillName}/SKILL.md` to its resources. When you call `agent.addPrompt(prompt)`, it adds `file://prompts/{promptName}.md`. - -### Example - -```ts -const agent = new Agent(app, 'dev', { - description: 'Development assistant', - prompt: 'You are a helpful development assistant.', - tools: [ - BuiltInTool.all({ allowed: true }), - BuiltInTool.shell({ - allow: [Shell.git.readonly(), Shell.npm.scripts()], - deny: [Shell.git.destructive()], - }), - ], -}); - -const skill = new Skill(app, 'typescript', { - description: 'TypeScript expertise', - instructions: '# TypeScript\n\nUse strict mode...', -}); - -const prompt = new Prompt(app, 'review', { - content: '# Code Review\n\nReview for correctness.', -}); - -agent.addSkill(skill); -agent.addPrompt(prompt); - -agent.addMcpServer('github', { - command: 'gh-mcp', - env: { GITHUB_TOKEN: '${GITHUB_TOKEN}' }, -}); -``` - -### Acceptance Criteria - -- [ ] `Agent` synthesizes identical JSON to an equivalent `CfgAgent` -- [ ] `addTool()` / `addMcpServer()` / `addHook()` / `addResource()` work after construction -- [ ] `ToolConfig` objects correctly split into `tools`, `allowedTools`, `toolsSettings` -- [ ] Tool settings deep-merge when same tool added twice -- [ ] `ToolConfig[]` (from `BuiltInTool.all()`) flattened correctly -- [ ] `addSkill(skill)` adds `skill://skills/{name}/SKILL.md` to resources -- [ ] `addPrompt(prompt)` adds `file://prompts/{name}.md` to resources -- [ ] Unit tests: basic synth, builder methods, tool decomposition, tool merging, skill/prompt registration - ---- - -## Task 3: `Skill` L2 Construct - -**File:** `src/skill.ts` - -A construct that synthesizes a Kiro skill directory with a `SKILL.md` file (YAML frontmatter + markdown body) and optional asset files. - -### Interface - -```ts -export interface SkillProps { - readonly name?: string; - readonly description: string; - readonly instructions: string; - readonly assets?: Record; - readonly metadata?: Record; -} -``` - -### Behavior - -- Implements `ISynthesizable` -- Synthesizes to `skills/{name}/SKILL.md` with YAML frontmatter containing `name`, `description`, and any extra `metadata` -- Body is the `instructions` content -- Optional `assets` map writes additional files into the skill directory -- `Skill.fromDirectory()` static factory reads an existing skill directory from disk, parsing `SKILL.md` frontmatter - -### Example - -```ts -const skill = new Skill(app, 'typescript', { - description: 'TypeScript development expertise', - instructions: '# TypeScript\n\nFollow strict TypeScript conventions...', - assets: { - 'tsconfig.example.json': '{ "strict": true }', - }, -}); - -// Load from existing directory -const existing = Skill.fromDirectory(app, 'review', './skills/review'); -``` - -### Acceptance Criteria - -- [ ] Synthesizes `skills/{name}/SKILL.md` with correct frontmatter -- [ ] Assets written to skill subdirectory -- [ ] `fromDirectory()` parses existing SKILL.md frontmatter and content -- [ ] Unit tests: basic synth, assets, fromDirectory - ---- - -## Task 4: `Prompt` L2 Construct - -**File:** `src/prompt.ts` - -A thin L2 wrapper over `CfgPrompt` that adds builder methods and a `fromFile()` factory. - -### Interface - -```ts -export interface PromptProps { - readonly name?: string; - readonly content: string; - readonly metadata?: Record; -} -``` - -Implement `Prompt` class extending `Construct`: - -- `readonly promptName: string` (public, set from `props.name ?? id`) — used by `Agent.addPrompt()` to build the `file://` resource URI -- Wraps `CfgPrompt` — delegates synthesis entirely -- `Prompt.fromFile()` static factory reads a markdown file from disk, parsing YAML frontmatter into metadata and body into content -- Provides `appendContent()` method for post-construction content additions (uses `Lazy` internally) - -### Example - -```ts -const prompt = new Prompt(app, 'review', { - content: '# Code Review\n\nReview for correctness and readability.', - metadata: { version: '2.0' }, -}); - -// Load from file -const fromDisk = Prompt.fromFile(app, 'security', './prompts/security-review.md'); -``` - -### Acceptance Criteria - -- [ ] Synthesizes identically to equivalent `CfgPrompt` -- [ ] `fromFile()` correctly parses frontmatter and content -- [ ] `appendContent()` works after construction -- [ ] Unit tests: basic synth, fromFile, appendContent - ---- - -## Task 5: Exports - -Update `src/index.ts` to add all new L2 exports alongside existing ones. - -### Acceptance Criteria - -- [ ] All L2 constructs importable from `@kiro/constructs` -- [ ] No circular dependencies - ---- - -## Implementation Notes - -### File Layout - -Everything lives in `packages/kiro-constructs` — single package, flat exports, domain folders internally. - -``` -packages/kiro-constructs/src/ -├── l1/ -│ ├── cfg-agent.ts # existing -│ └── cfg-prompt.ts # existing -├── tools/ -│ ├── index.ts # barrel -│ ├── tool-config.ts # ToolConfig interface -│ ├── built-in-tools.ts # BuiltInTool factories -│ ├── shell.ts # Shell permissions -│ └── shell-commands.ts # regex builders -├── agent.ts # Agent L2 -├── skill.ts # Skill L2 -├── prompt.ts # Prompt L2 -├── index.ts # add new exports alongside existing ones -└── ...existing files -``` - -All L2s export from the package root: `import { App, Agent, Skill, BuiltInTool, CfgAgent } from '@kiro/constructs'` - -### Design Principles - -1. **L2s compose L1s** — they don't bypass them. `Agent` creates a `CfgAgent` internally. `Prompt` wraps `CfgPrompt`. -2. **Tools are data** — `ToolConfig` is a plain object, not a construct. Tool factories (`BuiltInTool.shell()`) return data that the `Agent` knows how to decompose into L1 fields. -3. **Lazy rendering** — use `Lazy.any()` for any property that can be modified after construction via `addX()` methods. -4. **Static factories** — `fromFile()` and `fromDirectory()` for loading existing config from disk. Resolve paths relative to `App.sourceDir`. -5. **ISynthesizable** — L2s that produce output implement this interface. The `App` tree walk handles the rest. -6. **No breaking changes** — L1s remain the same. L2s are additive. - -### Dependencies - -- `gray-matter` — already in the repo for `CfgPrompt` frontmatter parsing. Reuse for `Skill` and `Prompt.fromFile()`. -- `lodash.merge` — needed for `Agent` tool settings merging. Add as a dependency. - -### Suggested Order - -1. Task 1 (ToolConfig + BuiltInTool + Shell) — foundation for Agent -2. Task 5 (exports) — wire up index.ts -3. Task 4 (Prompt) — simplest L2, validates the pattern -4. Task 2 (Agent) — core L2 with tool decomposition -5. Task 3 (Skill) — synthesizes to its own directory structure From 95f8131b9ff6d01762f61ad45653023ae6f47fb0 Mon Sep 17 00:00:00 2001 From: Alex Bello <77049455+alxbello@users.noreply.github.com> Date: Thu, 23 Apr 2026 14:39:12 -0500 Subject: [PATCH 5/7] fix: resolve eslint unsafe-any errors --- packages/kiro-constructs/src/agent.ts | 6 +++--- packages/kiro-constructs/test/agent.test.ts | 22 +++++++++++++-------- 2 files changed, 17 insertions(+), 11 deletions(-) diff --git a/packages/kiro-constructs/src/agent.ts b/packages/kiro-constructs/src/agent.ts index 3176dc4..7239ba9 100644 --- a/packages/kiro-constructs/src/agent.ts +++ b/packages/kiro-constructs/src/agent.ts @@ -32,9 +32,9 @@ function deepMerge(target: Record, source: Record, sVal as Record); + result[key] = deepMerge(tVal, sVal); } else { result[key] = sVal; } @@ -75,7 +75,7 @@ export class Agent extends Construct { if (props.hooks) { for (const [k, v] of Object.entries(props.hooks)) { - if (v) this._hooks.set(k as keyof CfgAgent.HooksProperty, [...v]); + if (v) this._hooks.set(k as keyof CfgAgent.HooksProperty, [...(v as CfgAgent.HookProperty[])]); } } diff --git a/packages/kiro-constructs/test/agent.test.ts b/packages/kiro-constructs/test/agent.test.ts index 2b4b007..02e0697 100644 --- a/packages/kiro-constructs/test/agent.test.ts +++ b/packages/kiro-constructs/test/agent.test.ts @@ -16,9 +16,11 @@ afterEach(() => { fs.rmSync(tmpDir, { recursive: true, force: true }); }); -async function synthAgent(app: App, name: string) { +async function synthAgent(app: App, name: string): Promise> { await app.synth(); - return JSON.parse(fs.readFileSync(path.join(outdir, 'agents', `${name}.json`), 'utf-8')); + return JSON.parse( + fs.readFileSync(path.join(outdir, 'agents', `${name}.json`), 'utf-8'), + ) as Record; } describe('Agent', () => { @@ -35,8 +37,9 @@ describe('Agent', () => { const config = await synthAgent(app, 'dev'); expect(config.tools).toEqual(['shell', 'write', 'read']); expect(config.allowedTools).toEqual(['shell']); - expect(config.toolsSettings.shell).toEqual({ denyByDefault: true }); - expect(config.toolsSettings.write).toEqual({ deniedPaths: ['.env'] }); + 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 () => { @@ -54,8 +57,9 @@ describe('Agent', () => { }); agent.addTool(BuiltInTool.shell({ allow: [Shell.npm.scripts()], deny: [Shell.git.destructive()] })); const config = await synthAgent(app, 'dev'); - expect(config.toolsSettings.shell.allowedCommands).toHaveLength(2); - expect(config.toolsSettings.shell.deniedCommands).toHaveLength(1); + 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 () => { @@ -71,8 +75,10 @@ describe('Agent', () => { const config = await synthAgent(app, 'dev'); expect(config.tools).toEqual(['write']); expect(config.allowedTools).toEqual(['write']); - expect(config.mcpServers.github).toEqual({ command: 'gh-mcp' }); - expect(config.hooks.agentSpawn).toHaveLength(1); + 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); }); From 933b861c699e9d343d8f29a2212e20a33f3b78e1 Mon Sep 17 00:00:00 2001 From: Alex Bello <77049455+alxbello@users.noreply.github.com> Date: Thu, 23 Apr 2026 14:41:36 -0500 Subject: [PATCH 6/7] style: run prettier on all files --- packages/kiro-constructs/src/agent.ts | 12 ++++++--- .../src/tools/built-in-tools.ts | 17 +++++++++---- packages/kiro-constructs/src/tools/shell.ts | 25 +++++++++++-------- packages/kiro-constructs/test/agent.test.ts | 9 +++---- packages/kiro-constructs/test/tools.test.ts | 16 +++++++++--- 5 files changed, 51 insertions(+), 28 deletions(-) diff --git a/packages/kiro-constructs/src/agent.ts b/packages/kiro-constructs/src/agent.ts index 7239ba9..23692aa 100644 --- a/packages/kiro-constructs/src/agent.ts +++ b/packages/kiro-constructs/src/agent.ts @@ -26,7 +26,10 @@ function isPlainObject(v: unknown): v is Record { return typeof v === 'object' && v !== null && !Array.isArray(v); } -function deepMerge(target: Record, source: Record): Record { +function deepMerge( + target: Record, + source: Record, +): Record { const result = { ...target }; for (const key of Object.keys(source)) { const tVal = target[key]; @@ -75,7 +78,8 @@ export class Agent extends Construct { 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 (v) + this._hooks.set(k as keyof CfgAgent.HooksProperty, [...(v as CfgAgent.HookProperty[])]); } } @@ -146,7 +150,7 @@ export class Agent extends Construct { } private renderTools(): string[] | undefined { - const names = this._tools.map(t => isToolConfig(t) ? t.toolName : t); + const names = this._tools.map((t) => (isToolConfig(t) ? t.toolName : t)); const deduped = [...new Set(names)]; return deduped.length ? deduped : undefined; } @@ -154,7 +158,7 @@ export class Agent extends Construct { private renderAllowedTools(): string[] | undefined { const names = this._tools .filter((t): t is ToolConfig => isToolConfig(t) && t.allowed === true) - .map(t => t.toolName); + .map((t) => t.toolName); const deduped = [...new Set(names)]; return deduped.length ? deduped : undefined; } diff --git a/packages/kiro-constructs/src/tools/built-in-tools.ts b/packages/kiro-constructs/src/tools/built-in-tools.ts index 9a39687..28a1777 100644 --- a/packages/kiro-constructs/src/tools/built-in-tools.ts +++ b/packages/kiro-constructs/src/tools/built-in-tools.ts @@ -36,8 +36,8 @@ export interface AllToolsProps extends 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.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); @@ -94,12 +94,19 @@ export class BuiltInTool { 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; + 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 }; + 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; diff --git a/packages/kiro-constructs/src/tools/shell.ts b/packages/kiro-constructs/src/tools/shell.ts index 8341ec4..e7fcace 100644 --- a/packages/kiro-constructs/src/tools/shell.ts +++ b/packages/kiro-constructs/src/tools/shell.ts @@ -6,22 +6,27 @@ export interface IShellPermission { 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')] }), + 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')] }), + inspect: (): IShellPermission => ({ + patterns: [fileOps('ls', 'cat', 'head', 'tail', 'wc', 'grep', 'find', 'tree')], + }), }; static readonly npm = { - scripts: (): IShellPermission => - ({ patterns: [npm('run', 'test', 'build', 'install')] }), + scripts: (): IShellPermission => ({ patterns: [npm('run', 'test', 'build', 'install')] }), }; static command(pattern: string): IShellPermission { diff --git a/packages/kiro-constructs/test/agent.test.ts b/packages/kiro-constructs/test/agent.test.ts index 02e0697..425344c 100644 --- a/packages/kiro-constructs/test/agent.test.ts +++ b/packages/kiro-constructs/test/agent.test.ts @@ -55,7 +55,9 @@ describe('Agent', () => { description: 'Dev', tools: [BuiltInTool.shell({ allow: [Shell.git.readonly()] })], }); - agent.addTool(BuiltInTool.shell({ allow: [Shell.npm.scripts()], deny: [Shell.git.destructive()] })); + 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); @@ -101,9 +103,6 @@ describe('Agent', () => { 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', - ]); + expect(config.resources).toEqual(['skill://skills/ts/SKILL.md', 'file://prompts/review.md']); }); }); diff --git a/packages/kiro-constructs/test/tools.test.ts b/packages/kiro-constructs/test/tools.test.ts index 279030b..f9ab34a 100644 --- a/packages/kiro-constructs/test/tools.test.ts +++ b/packages/kiro-constructs/test/tools.test.ts @@ -42,11 +42,19 @@ describe('BuiltInTool', () => { 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.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); + expect(tools.find((t) => t.toolName === 'shell')!.allowed).toBe(false); + expect(tools.filter((t) => t.toolName !== 'shell').every((t) => t.allowed)).toBe(true); }); }); From 43159ae855ee1a60e8c460241ff47bce1b0fc910 Mon Sep 17 00:00:00 2001 From: Alex Bello <77049455+alxbello@users.noreply.github.com> Date: Thu, 23 Apr 2026 14:42:18 -0500 Subject: [PATCH 7/7] docs: add AGENTS.md with pre-push checklist --- AGENTS.md | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 AGENTS.md 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.