@humanlayer/agentlayer-justbash 0.0.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/dist/index.d.ts +3 -0
  2. package/dist/index.d.ts.map +1 -0
  3. package/dist/index.js +630 -0
  4. package/dist/index.js.map +22 -0
  5. package/dist/prompts/index.d.ts +29 -0
  6. package/dist/prompts/index.d.ts.map +1 -0
  7. package/dist/prompts/index.js +123 -0
  8. package/dist/prompts/index.js.map +10 -0
  9. package/dist/tools/apply-patch.d.ts +9 -0
  10. package/dist/tools/apply-patch.d.ts.map +1 -0
  11. package/dist/tools/bash.d.ts +8 -0
  12. package/dist/tools/bash.d.ts.map +1 -0
  13. package/dist/tools/code-search.d.ts +12 -0
  14. package/dist/tools/code-search.d.ts.map +1 -0
  15. package/dist/tools/edit.d.ts +12 -0
  16. package/dist/tools/edit.d.ts.map +1 -0
  17. package/dist/tools/glob.d.ts +6 -0
  18. package/dist/tools/glob.d.ts.map +1 -0
  19. package/dist/tools/grep.d.ts +8 -0
  20. package/dist/tools/grep.d.ts.map +1 -0
  21. package/dist/tools/index.d.ts +13 -0
  22. package/dist/tools/index.d.ts.map +1 -0
  23. package/dist/tools/index.js +510 -0
  24. package/dist/tools/index.js.map +21 -0
  25. package/dist/tools/list.d.ts +7 -0
  26. package/dist/tools/list.d.ts.map +1 -0
  27. package/dist/tools/read.d.ts +7 -0
  28. package/dist/tools/read.d.ts.map +1 -0
  29. package/dist/tools/skill.d.ts +10 -0
  30. package/dist/tools/skill.d.ts.map +1 -0
  31. package/dist/tools/web-fetch.d.ts +7 -0
  32. package/dist/tools/web-fetch.d.ts.map +1 -0
  33. package/dist/tools/web-search.d.ts +16 -0
  34. package/dist/tools/web-search.d.ts.map +1 -0
  35. package/dist/tools/write.d.ts +6 -0
  36. package/dist/tools/write.d.ts.map +1 -0
  37. package/package.json +59 -0
  38. package/src/index.ts +2 -0
  39. package/src/prompts/index.ts +161 -0
  40. package/src/tools/apply-patch.ts +121 -0
  41. package/src/tools/apply-patch.txt +35 -0
  42. package/src/tools/bash.ts +20 -0
  43. package/src/tools/bash.txt +114 -0
  44. package/src/tools/code-search.ts +119 -0
  45. package/src/tools/edit.ts +58 -0
  46. package/src/tools/edit.txt +10 -0
  47. package/src/tools/glob.ts +33 -0
  48. package/src/tools/glob.txt +6 -0
  49. package/src/tools/grep.ts +53 -0
  50. package/src/tools/grep.txt +8 -0
  51. package/src/tools/index.ts +12 -0
  52. package/src/tools/list.ts +41 -0
  53. package/src/tools/list.txt +5 -0
  54. package/src/tools/read.ts +16 -0
  55. package/src/tools/read.txt +14 -0
  56. package/src/tools/skill.ts +49 -0
  57. package/src/tools/web-fetch.ts +97 -0
  58. package/src/tools/web-fetch.txt +10 -0
  59. package/src/tools/web-search.ts +59 -0
  60. package/src/tools/write.ts +26 -0
  61. package/src/tools/write.txt +8 -0
@@ -0,0 +1,119 @@
1
+ import type { CodeSearchInput } from '@humanlayer/agentlayer-core/interfaces'
2
+ import { CodeSearchTool } from '@humanlayer/agentlayer-core/interfaces'
3
+ import { CODE_SEARCH_DESCRIPTION } from '@humanlayer/agentlayer-core/prompts'
4
+ import type { Bash } from 'just-bash'
5
+
6
+ const DEFAULT_TIMEOUT_SEC = 30
7
+ const CONTEXT7_BASE_URL = 'https://context7.com'
8
+
9
+ export interface JustBashCodeSearchOptions {
10
+ exaApiKey?: string
11
+ context7ApiKey?: string
12
+ timeoutSec?: number
13
+ }
14
+
15
+ async function fetchExaViaBash(
16
+ bash: Bash,
17
+ input: CodeSearchInput,
18
+ apiKey: string,
19
+ timeoutSec: number,
20
+ ): Promise<string | null> {
21
+ try {
22
+ const query = `${input.query} -- for ${input.packageName} in ${input.language}`
23
+ const payload = JSON.stringify({ query, tokensNum: 5000 })
24
+ const escapedPayload = payload.replace(/'/g, "'\\''")
25
+
26
+ const result = await bash.exec(
27
+ `curl -s --max-time ${timeoutSec} ` +
28
+ `-H "Content-Type: application/json" ` +
29
+ `-H "x-api-key: ${apiKey}" ` +
30
+ `-d '${escapedPayload}' ` +
31
+ `https://api.exa.ai/context`,
32
+ )
33
+
34
+ if (result.exitCode !== 0) return null
35
+
36
+ const data = JSON.parse(result.stdout) as { response?: string }
37
+ return data.response ?? null
38
+ } catch {
39
+ return null
40
+ }
41
+ }
42
+
43
+ async function fetchContext7ViaBash(
44
+ bash: Bash,
45
+ input: CodeSearchInput,
46
+ apiKey: string,
47
+ timeoutSec: number,
48
+ ): Promise<string | null> {
49
+ try {
50
+ // Step 1: search for the library
51
+ const searchQuery = encodeURIComponent(input.query)
52
+ const libName = encodeURIComponent(input.packageName)
53
+
54
+ const searchResult = await bash.exec(
55
+ `curl -s --max-time ${timeoutSec} ` +
56
+ `-H "Authorization: Bearer ${apiKey}" ` +
57
+ `"${CONTEXT7_BASE_URL}/api/v2/libs/search?query=${searchQuery}&libraryName=${libName}"`,
58
+ )
59
+
60
+ if (searchResult.exitCode !== 0) return null
61
+
62
+ const searchData = JSON.parse(searchResult.stdout) as {
63
+ results?: Array<{ id: string; title: string; trustScore?: number }>
64
+ }
65
+ const libraries = searchData.results ?? []
66
+ if (libraries.length === 0) return null
67
+
68
+ const best = libraries.reduce((a, b) => ((b.trustScore ?? 0) > (a.trustScore ?? 0) ? b : a))
69
+
70
+ // Step 2: fetch context for the selected library
71
+ const contextQuery = encodeURIComponent(input.query)
72
+ const libId = encodeURIComponent(best.id)
73
+
74
+ const contextResult = await bash.exec(
75
+ `curl -s --max-time ${timeoutSec} ` +
76
+ `-H "Authorization: Bearer ${apiKey}" ` +
77
+ `"${CONTEXT7_BASE_URL}/api/v2/context?query=${contextQuery}&libraryId=${libId}"`,
78
+ )
79
+
80
+ if (contextResult.exitCode !== 0) return null
81
+ return contextResult.stdout
82
+ } catch {
83
+ return null
84
+ }
85
+ }
86
+
87
+ export function createCodeSearchTool(bash: Bash, opts: JustBashCodeSearchOptions) {
88
+ if (!opts.exaApiKey && !opts.context7ApiKey) {
89
+ throw new Error('At least one API key (exaApiKey or context7ApiKey) is required')
90
+ }
91
+
92
+ const timeoutSec = opts.timeoutSec ?? DEFAULT_TIMEOUT_SEC
93
+
94
+ return CodeSearchTool.define(
95
+ async (input: CodeSearchInput): Promise<string> => {
96
+ const [exaResult, c7Result] = await Promise.all([
97
+ opts.exaApiKey ? fetchExaViaBash(bash, input, opts.exaApiKey, timeoutSec) : Promise.resolve(null),
98
+ opts.context7ApiKey
99
+ ? fetchContext7ViaBash(bash, input, opts.context7ApiKey, timeoutSec)
100
+ : Promise.resolve(null),
101
+ ])
102
+
103
+ const parts: string[] = []
104
+ if (c7Result) {
105
+ parts.push(`## Context7 Documentation\n\n${c7Result}`)
106
+ }
107
+ if (exaResult) {
108
+ parts.push(`## Exa Search Results\n\n${exaResult}`)
109
+ }
110
+
111
+ if (parts.length === 0) {
112
+ return `No documentation found for "${input.packageName}" with query: ${input.query}`
113
+ }
114
+
115
+ return parts.join('\n\n---\n\n')
116
+ },
117
+ { description: CODE_SEARCH_DESCRIPTION },
118
+ )
119
+ }
@@ -0,0 +1,58 @@
1
+ import { EditTool } from '@humanlayer/agentlayer-core/interfaces'
2
+ import { EDIT_DESCRIPTION } from '@humanlayer/agentlayer-core/prompts'
3
+ import type { Bash } from 'just-bash'
4
+
5
+ export function createEditTool(bash: Bash) {
6
+ return EditTool.define(
7
+ async (input) => {
8
+ // Read the file content
9
+ const catResult = await bash.exec(`cat "${input.file_path}"`)
10
+ if (catResult.exitCode !== 0) {
11
+ throw new Error(`File ${input.file_path} not found`)
12
+ }
13
+
14
+ const content = catResult.stdout
15
+
16
+ if (!content.includes(input.old_string)) {
17
+ return { content, matchCount: 0 }
18
+ }
19
+
20
+ let updated: string
21
+ let matchCount: number
22
+
23
+ if (input.replace_all) {
24
+ let count = 0
25
+ let pos = 0
26
+ while (true) {
27
+ const idx = content.indexOf(input.old_string, pos)
28
+ if (idx === -1) break
29
+ count++
30
+ pos = idx + input.old_string.length
31
+ }
32
+ updated = content.split(input.old_string).join(input.new_string)
33
+ matchCount = count
34
+ } else {
35
+ // Single replacement — check for multiple matches
36
+ const firstIdx = content.indexOf(input.old_string)
37
+ const lastIdx = content.lastIndexOf(input.old_string)
38
+ if (firstIdx !== lastIdx) {
39
+ throw new Error(
40
+ 'Found multiple matches for old_string. Provide more surrounding context to make the match unique.',
41
+ )
42
+ }
43
+ updated = content.replace(input.old_string, input.new_string)
44
+ matchCount = 1
45
+ }
46
+
47
+ // Write the updated content back using a heredoc
48
+ const DELIM = 'EDITEOF_8f3a2b1c'
49
+ const writeResult = await bash.exec(`cat > "${input.file_path}" <<'${DELIM}'\n${updated}\n${DELIM}`)
50
+ if (writeResult.exitCode !== 0) {
51
+ throw new Error(`Failed to write file ${input.file_path}: ${writeResult.stderr}`)
52
+ }
53
+
54
+ return { content: updated, matchCount }
55
+ },
56
+ { description: EDIT_DESCRIPTION },
57
+ )
58
+ }
@@ -0,0 +1,10 @@
1
+ Performs exact string replacements in files.
2
+
3
+ Usage:
4
+ - You must use your `Read` tool at least once in the conversation before editing. This tool will error if you attempt an edit without reading the file.
5
+ - When editing text from Read tool output, ensure you preserve the exact indentation (tabs/spaces) as it appears AFTER the line number prefix. The line number prefix format is: spaces + line number + arrow. Everything after that arrow is the actual file content to match. Never include any part of the line number prefix in the oldString or newString.
6
+ - ALWAYS prefer editing existing files in the codebase. NEVER write new files unless explicitly required.
7
+ - Only use emojis if the user explicitly requests it. Avoid adding emojis to files unless asked.
8
+ - The edit will FAIL if `oldString` is not found in the file. Either provide a larger string with more surrounding context to make it unique or use `replaceAll` to change every instance of `oldString`.
9
+ - The edit will FAIL if `oldString` matches multiple locations and `replaceAll` is not set.
10
+ - Use `replaceAll` for replacing and renaming strings across the file. This parameter is useful if you want to rename a variable for instance.
@@ -0,0 +1,33 @@
1
+ import { GlobTool } from '@humanlayer/agentlayer-core/interfaces'
2
+ import { GLOB_DESCRIPTION } from '@humanlayer/agentlayer-core/prompts'
3
+ import type { Bash } from 'just-bash'
4
+
5
+ export function createGlobTool(bash: Bash) {
6
+ return GlobTool.define(
7
+ async (input) => {
8
+ const searchPath = input.path ?? '.'
9
+ // Use rg --files with glob filter — rg is typically available in just-bash environments
10
+ const result = await bash.exec(`rg --files -g "${input.pattern}" "${searchPath}" 2>/dev/null`)
11
+
12
+ if (result.exitCode !== 0 && result.exitCode !== 1) {
13
+ // Non-zero / non-empty exit may mean rg not available — fall back to find
14
+ const findResult = await bash.exec(
15
+ `find "${searchPath}" -type f -name "${input.pattern}" 2>/dev/null | head -100`,
16
+ )
17
+ if (findResult.exitCode !== 0) {
18
+ return []
19
+ }
20
+ return findResult.stdout
21
+ .split('\n')
22
+ .map((l) => l.trim())
23
+ .filter(Boolean)
24
+ }
25
+
26
+ return result.stdout
27
+ .split('\n')
28
+ .map((l) => l.trim())
29
+ .filter(Boolean)
30
+ },
31
+ { description: GLOB_DESCRIPTION },
32
+ )
33
+ }
@@ -0,0 +1,6 @@
1
+ - Fast file pattern matching tool that works with any codebase size
2
+ - Supports glob patterns like "**/*.js" or "src/**/*.ts"
3
+ - Returns matching file paths sorted by modification time
4
+ - Use this tool when you need to find files by name patterns
5
+ - When you are doing an open-ended search that may require multiple rounds of globbing and grepping, use the Task tool instead
6
+ - You have the capability to call multiple tools in a single response. It is always better to speculatively perform multiple searches as a batch that are potentially useful.
@@ -0,0 +1,53 @@
1
+ import type { GrepMatch } from '@humanlayer/agentlayer-core/interfaces'
2
+ import { GrepTool } from '@humanlayer/agentlayer-core/interfaces'
3
+ import { GREP_DESCRIPTION } from '@humanlayer/agentlayer-core/prompts'
4
+ import type { Bash } from 'just-bash'
5
+
6
+ const MAX_MATCHES = 100
7
+
8
+ export function createGrepTool(bash: Bash) {
9
+ return GrepTool.define(
10
+ async (input) => {
11
+ const searchPath = input.path ?? '.'
12
+ let cmd = `rg -nH --hidden --no-messages --regexp "${input.pattern}"`
13
+ if (input.include) {
14
+ cmd += ` --glob "${input.include}"`
15
+ }
16
+ cmd += ` "${searchPath}"`
17
+
18
+ const result = await bash.exec(cmd)
19
+
20
+ // Exit code 1 = no matches
21
+ if (result.exitCode === 1) {
22
+ return []
23
+ }
24
+
25
+ if (result.exitCode !== 0) {
26
+ throw new Error(`grep failed with exit code ${result.exitCode}: ${result.stderr}`)
27
+ }
28
+
29
+ // Parse `file:line:content` output lines
30
+ const matches: GrepMatch[] = []
31
+ for (const line of result.stdout.split('\n')) {
32
+ if (!line) continue
33
+ const colonIdx = line.indexOf(':')
34
+ if (colonIdx === -1) continue
35
+ const afterFile = line.indexOf(':', colonIdx + 1)
36
+ if (afterFile === -1) continue
37
+
38
+ const file = line.slice(0, colonIdx)
39
+ const lineNum = Number.parseInt(line.slice(colonIdx + 1, afterFile), 10)
40
+ const content = line.slice(afterFile + 1)
41
+
42
+ if (!Number.isNaN(lineNum)) {
43
+ matches.push({ file, line: lineNum, content })
44
+ }
45
+
46
+ if (matches.length >= MAX_MATCHES) break
47
+ }
48
+
49
+ return matches
50
+ },
51
+ { description: GREP_DESCRIPTION },
52
+ )
53
+ }
@@ -0,0 +1,8 @@
1
+ - Fast content search tool that works with any codebase size
2
+ - Searches file contents using regular expressions
3
+ - Supports full regex syntax (eg. "log.*Error", "function\s+\w+", etc.)
4
+ - Filter files by pattern with the include parameter (eg. "*.js", "*.{ts,tsx}")
5
+ - Returns file paths and line numbers with at least one match sorted by modification time
6
+ - Use this tool when you need to find files containing specific patterns
7
+ - If you need to identify/count the number of matches within files, use the Bash tool with `rg` (ripgrep) directly. Do NOT use `grep`.
8
+ - When you are doing an open-ended search that may require multiple rounds of globbing and grepping, use the Task tool instead
@@ -0,0 +1,12 @@
1
+ export { createApplyPatchTool } from './apply-patch'
2
+ export { createJustBashTool } from './bash'
3
+ export { createCodeSearchTool, type JustBashCodeSearchOptions } from './code-search'
4
+ export { createEditTool } from './edit'
5
+ export { createGlobTool } from './glob'
6
+ export { createGrepTool } from './grep'
7
+ export { createListTool } from './list'
8
+ export { createJustBashReadTool } from './read'
9
+ export { createSkillToolFromVFS } from './skill'
10
+ export { createWebFetchTool } from './web-fetch'
11
+ export { createWebSearchTool, type JustBashWebSearchOptions } from './web-search'
12
+ export { createWriteTool } from './write'
@@ -0,0 +1,41 @@
1
+ import type { ListEntry } from '@humanlayer/agentlayer-core/interfaces'
2
+ import { ListTool } from '@humanlayer/agentlayer-core/interfaces'
3
+ import { LIST_DESCRIPTION } from '@humanlayer/agentlayer-core/prompts'
4
+ import type { Bash } from 'just-bash'
5
+ export function createListTool(bash: Bash) {
6
+ return ListTool.define(
7
+ async (input) => {
8
+ const dirPath = input.path ?? '.'
9
+
10
+ // Use ls -1F: appends '/' to directories, nothing to files
11
+ const result = await bash.exec(`ls -1F "${dirPath}" 2>/dev/null`)
12
+
13
+ if (result.exitCode !== 0) {
14
+ throw new Error(`Failed to list directory ${dirPath}: ${result.stderr}`)
15
+ }
16
+
17
+ const ignorePatterns = new Set<string>(['node_modules', '.git', 'dist', 'build', ...(input.ignore ?? [])])
18
+
19
+ const entries: ListEntry[] = []
20
+ for (const raw of result.stdout.split('\n')) {
21
+ const line = raw.trim()
22
+ if (!line) continue
23
+
24
+ if (line.endsWith('/')) {
25
+ // Directory
26
+ const name = line.slice(0, -1)
27
+ if (ignorePatterns.has(name)) continue
28
+ entries.push({ name, type: 'directory' })
29
+ } else {
30
+ // File (strip any trailing decorator: *, @, |, =, >)
31
+ const name = line.replace(/[*@|=>]$/, '')
32
+ if (ignorePatterns.has(name)) continue
33
+ entries.push({ name, type: 'file' })
34
+ }
35
+ }
36
+
37
+ return entries
38
+ },
39
+ { description: LIST_DESCRIPTION },
40
+ )
41
+ }
@@ -0,0 +1,5 @@
1
+ Lists files and directories in a given path.
2
+
3
+ - The path parameter must be absolute; omit it to use the current workspace directory.
4
+ - You can optionally provide an array of glob patterns to ignore with the ignore parameter.
5
+ - You should generally prefer the Glob and Grep tools if you know which directories to search.
@@ -0,0 +1,16 @@
1
+ import { ReadTool } from '@humanlayer/agentlayer-core/interfaces'
2
+ import { READ_DESCRIPTION } from '@humanlayer/agentlayer-core/prompts'
3
+ import type { Bash } from 'just-bash'
4
+
5
+ export function createJustBashReadTool(bash: Bash) {
6
+ return ReadTool.define(
7
+ async (input) => {
8
+ const result = await bash.exec(`cat "${input.file_path}"`)
9
+ if (result.exitCode !== 0) {
10
+ throw new Error(`File not found: ${input.file_path}`)
11
+ }
12
+ return result.stdout
13
+ },
14
+ { description: READ_DESCRIPTION },
15
+ )
16
+ }
@@ -0,0 +1,14 @@
1
+ Read a file or directory from the local filesystem. If the path does not exist, an error is returned.
2
+
3
+ Usage:
4
+ - The filePath parameter should be an absolute path.
5
+ - By default, this tool returns up to 2000 lines from the start of the file.
6
+ - The offset parameter is the line number to start from (1-indexed).
7
+ - To read later sections, call this tool again with a larger offset.
8
+ - Use the grep tool to find specific content in large files or files with long lines.
9
+ - If you are unsure of the correct file path, use the glob tool to look up filenames by glob pattern.
10
+ - Contents are returned with each line prefixed by its line number as `<line>: <content>`. For example, if a file has contents "foo\n", you will receive "1: foo\n". For directories, entries are returned one per line (without line numbers) with a trailing `/` for subdirectories.
11
+ - Any line longer than 2000 characters is truncated.
12
+ - Call this tool in parallel when you know there are multiple files you want to read.
13
+ - Avoid tiny repeated slices (30 line chunks). If you need more context, read a larger window.
14
+ - This tool can read image files and PDFs and return them as file attachments.
@@ -0,0 +1,49 @@
1
+ import { createSkillTool } from '@humanlayer/agentlayer-core'
2
+ import type { Skill } from '@humanlayer/agentlayer-core/interfaces'
3
+ import type { Bash } from 'just-bash'
4
+
5
+ function parseFrontmatterDescription(content: string): string | null {
6
+ const match = content.match(/^---\n([\s\S]*?)\n---/)
7
+ if (!match) return null
8
+ const fmMatch = match[1]?.match(/description:\s*(.+)/)
9
+ return fmMatch?.[1]?.trim() ?? null
10
+ }
11
+
12
+ function parseFirstHeading(content: string): string | null {
13
+ const match = content.match(/^#\s+(.+)/m)
14
+ return match?.[1]?.trim() ?? null
15
+ }
16
+
17
+ export async function createSkillToolFromVFS(bash: Bash, opts: { dirs: string | string[]; skills?: Skill[] }) {
18
+ const directories = Array.isArray(opts.dirs) ? opts.dirs : [opts.dirs]
19
+ const resolved: Skill[] = []
20
+
21
+ for (const dir of directories) {
22
+ let lsResult: { stdout: string; exitCode: number }
23
+ try {
24
+ lsResult = await bash.exec(`ls "${dir}"/*.md 2>/dev/null`)
25
+ } catch {
26
+ continue
27
+ }
28
+ if (lsResult.exitCode !== 0) continue
29
+
30
+ const files = lsResult.stdout.trim().split('\n').filter(Boolean)
31
+ for (const filePath of files) {
32
+ const catResult = await bash.exec(`cat "${filePath}"`)
33
+ if (catResult.exitCode !== 0) continue
34
+
35
+ const content = catResult.stdout
36
+ const name = filePath.split('/').pop()?.replace('.md', '') ?? filePath
37
+ const description = parseFrontmatterDescription(content) ?? parseFirstHeading(content) ?? name
38
+
39
+ resolved.push({ name, description, content })
40
+ }
41
+ }
42
+
43
+ const mergedMap = new Map(resolved.map((s) => [s.name, s]))
44
+ for (const skill of opts.skills ?? []) {
45
+ mergedMap.set(skill.name, skill)
46
+ }
47
+
48
+ return createSkillTool({ skills: [...mergedMap.values()] })
49
+ }
@@ -0,0 +1,97 @@
1
+ import { WebFetchTool } from '@humanlayer/agentlayer-core'
2
+ import { WEB_FETCH_DESCRIPTION } from '@humanlayer/agentlayer-core/prompts'
3
+ import type { Bash } from 'just-bash'
4
+ import TurndownService from 'turndown'
5
+
6
+ const MAX_RESPONSE_SIZE = 5 * 1024 * 1024 // 5MB in bytes
7
+ const MAX_TIMEOUT_MS = 120_000
8
+
9
+ /**
10
+ * Strip HTML tags from a string to produce plain text.
11
+ */
12
+ function stripHtmlTags(html: string): string {
13
+ return html
14
+ .replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, '')
15
+ .replace(/<style\b[^<]*(?:(?!<\/style>)<[^<]*)*<\/style>/gi, '')
16
+ .replace(/<[^>]+>/g, '')
17
+ .replace(/&amp;/g, '&')
18
+ .replace(/&lt;/g, '<')
19
+ .replace(/&gt;/g, '>')
20
+ .replace(/&quot;/g, '"')
21
+ .replace(/&#39;/g, "'")
22
+ .replace(/&nbsp;/g, ' ')
23
+ .replace(/\n{3,}/g, '\n\n')
24
+ .trim()
25
+ }
26
+
27
+ export function createWebFetchTool(bash: Bash) {
28
+ const turndown = new TurndownService({
29
+ headingStyle: 'atx',
30
+ codeBlockStyle: 'fenced',
31
+ })
32
+ turndown.remove(['script', 'style', 'meta', 'link', 'noscript', 'iframe'])
33
+
34
+ return WebFetchTool.define(
35
+ async (input) => {
36
+ if (!input.url.startsWith('http://') && !input.url.startsWith('https://')) {
37
+ throw new Error('URL must start with http:// or https://')
38
+ }
39
+
40
+ const timeoutSec = Math.min(input.timeout, MAX_TIMEOUT_MS) / 1000
41
+ const maxSizeBytes = MAX_RESPONSE_SIZE
42
+
43
+ // curl flags:
44
+ // -s: silent, -L: follow redirects
45
+ // --max-filesize: reject oversized responses
46
+ // --max-time: timeout
47
+ // -A: User-Agent
48
+ // -w '\n%{http_code}': append status code on last line
49
+ const result = await bash.exec(
50
+ `curl -sL --max-filesize ${maxSizeBytes} --max-time ${timeoutSec} ` +
51
+ `-A "Mozilla/5.0 (compatible; agent/1.0)" ` +
52
+ `-w '\\n%{http_code}' "${input.url}"`,
53
+ )
54
+
55
+ if (result.exitCode !== 0) {
56
+ // curl exit code 28 = timeout, 63 = file too large
57
+ if (result.exitCode === 28) {
58
+ throw new Error(`Request timed out after ${input.timeout}ms`)
59
+ }
60
+ if (result.exitCode === 63) {
61
+ throw new Error('Response too large (exceeds 5MB limit)')
62
+ }
63
+ throw new Error(`curl failed (exit ${result.exitCode}): ${result.stderr}`)
64
+ }
65
+
66
+ // Split off the status code appended by -w
67
+ const lines = result.stdout.split('\n')
68
+ const statusLine = lines[lines.length - 1]?.trim() ?? ''
69
+ const statusCode = Number.parseInt(statusLine, 10)
70
+ const body = lines.slice(0, -1).join('\n')
71
+
72
+ if (!Number.isNaN(statusCode) && statusCode >= 400) {
73
+ throw new Error(`Request failed with status code: ${statusCode}`)
74
+ }
75
+
76
+ if (input.format === 'html') {
77
+ return body
78
+ }
79
+
80
+ const isHtml = body.trimStart().startsWith('<!') || body.trimStart().toLowerCase().startsWith('<html')
81
+
82
+ if (input.format === 'text') {
83
+ if (isHtml) {
84
+ return stripHtmlTags(body)
85
+ }
86
+ return body
87
+ }
88
+
89
+ // format === 'markdown' (default)
90
+ if (isHtml) {
91
+ return turndown.turndown(body)
92
+ }
93
+ return body
94
+ },
95
+ { description: WEB_FETCH_DESCRIPTION },
96
+ )
97
+ }
@@ -0,0 +1,10 @@
1
+ Fetches content from a URL and returns it in the requested format.
2
+
3
+ Usage:
4
+ - Provide a full URL starting with http:// or https://
5
+ - The format parameter controls output: "markdown" (default) converts HTML to readable markdown, "text" strips all HTML tags, "html" returns the raw HTML
6
+ - Default timeout is 30 seconds; maximum is 120 seconds
7
+ - Responses larger than 5MB are rejected
8
+ - Use markdown format for reading documentation, articles, or web pages
9
+ - Use html format only when you need the raw HTML structure
10
+ - Use text format when you need plain text without any markup
@@ -0,0 +1,59 @@
1
+ import type { WebSearchInput, WebSearchResult } from '@humanlayer/agentlayer-core'
2
+ import { WebSearchTool } from '@humanlayer/agentlayer-core'
3
+ import { WEB_SEARCH_DESCRIPTION } from '@humanlayer/agentlayer-core/prompts'
4
+ import type { Bash } from 'just-bash'
5
+
6
+ const DEFAULT_TIMEOUT_SEC = 25
7
+
8
+ export interface JustBashWebSearchOptions {
9
+ exaApiKey: string
10
+ timeoutSec?: number
11
+ }
12
+
13
+ export function createWebSearchTool(bash: Bash, opts: JustBashWebSearchOptions) {
14
+ const timeoutSec = opts.timeoutSec ?? DEFAULT_TIMEOUT_SEC
15
+
16
+ return WebSearchTool.define(
17
+ async (input: WebSearchInput): Promise<WebSearchResult> => {
18
+ const payload = JSON.stringify({
19
+ query: input.query,
20
+ numResults: input.numResults,
21
+ contents: { text: { maxCharacters: 500 } },
22
+ })
23
+
24
+ // Escape single quotes in payload for shell safety
25
+ const escapedPayload = payload.replace(/'/g, "'\\''")
26
+
27
+ const result = await bash.exec(
28
+ `curl -s --max-time ${timeoutSec} ` +
29
+ `-H "Content-Type: application/json" ` +
30
+ `-H "x-api-key: ${opts.exaApiKey}" ` +
31
+ `-d '${escapedPayload}' ` +
32
+ `https://api.exa.ai/search`,
33
+ )
34
+
35
+ if (result.exitCode !== 0) {
36
+ if (result.exitCode === 28) {
37
+ throw new Error('Search request timed out')
38
+ }
39
+ throw new Error(`curl failed (exit ${result.exitCode}): ${result.stderr}`)
40
+ }
41
+
42
+ let data: { results?: Array<{ title?: string; url?: string; text?: string; snippet?: string }> }
43
+ try {
44
+ data = JSON.parse(result.stdout)
45
+ } catch {
46
+ throw new Error(`Failed to parse search response: ${result.stdout.slice(0, 200)}`)
47
+ }
48
+
49
+ const results = (data.results ?? []).map((r) => ({
50
+ title: r.title ?? '',
51
+ url: r.url ?? '',
52
+ snippet: r.text ?? r.snippet ?? '',
53
+ }))
54
+
55
+ return { results }
56
+ },
57
+ { description: WEB_SEARCH_DESCRIPTION },
58
+ )
59
+ }
@@ -0,0 +1,26 @@
1
+ import { WriteTool } from '@humanlayer/agentlayer-core'
2
+ import { WRITE_DESCRIPTION } from '@humanlayer/agentlayer-core/prompts'
3
+ import type { Bash } from 'just-bash'
4
+
5
+ export function createWriteTool(bash: Bash) {
6
+ return WriteTool.define(
7
+ async (input) => {
8
+ // Ensure parent directory exists
9
+ const dirResult = await bash.exec(`mkdir -p "$(dirname "${input.file_path}")"`)
10
+ if (dirResult.exitCode !== 0) {
11
+ throw new Error(`Failed to create parent directory for ${input.file_path}: ${dirResult.stderr}`)
12
+ }
13
+
14
+ // Write content using a heredoc to handle special characters safely
15
+ // We use a unique delimiter to avoid conflicts with content
16
+ const DELIM = 'WRITEOF_8f3a2b1c'
17
+ const writeResult = await bash.exec(`cat > "${input.file_path}" <<'${DELIM}'\n${input.content}\n${DELIM}`)
18
+ if (writeResult.exitCode !== 0) {
19
+ throw new Error(`Failed to write file ${input.file_path}: ${writeResult.stderr}`)
20
+ }
21
+
22
+ return `Successfully wrote to ${input.file_path}`
23
+ },
24
+ { description: WRITE_DESCRIPTION },
25
+ )
26
+ }
@@ -0,0 +1,8 @@
1
+ Writes a file to the local filesystem.
2
+
3
+ Usage:
4
+ - This tool will overwrite the existing file if there is one at the provided path.
5
+ - If this is an existing file, you MUST use the Read tool first to read the file's contents. This tool will fail if you did not read the file first.
6
+ - ALWAYS prefer editing existing files in the codebase. NEVER write new files unless explicitly required.
7
+ - NEVER proactively create documentation files (*.md) or README files. Only create documentation files if explicitly requested by the User.
8
+ - Only use emojis if the user explicitly requests it. Avoid writing emojis to files unless asked.