commit 7e249eaa0f17e9af5d174665c156f09800957589
Author: Ben Sima <ben@bensima.com>
Date: Thu Jan 1 18:33:16 2026
Omni/Agent: Support shebang execution of markdown files
When the prompt argument is a path to a .md/.markdown file,
read the file content as the prompt instead of using the path literally.
This enables executable markdown skills:
#!/usr/bin/env agent
Do something useful.
Then run:
chmod +x skill.md
./skill.md
The shebang line is automatically stripped from the content.
Task-Id: t-334
diff --git a/Omni/Agent.hs b/Omni/Agent.hs
index 228858ac..573585e8 100644
--- a/Omni/Agent.hs
+++ b/Omni/Agent.hs
@@ -39,8 +39,8 @@ import qualified Omni.Agent.Provider as Provider
import qualified Omni.Agent.Tools as Tools
import qualified Omni.Cli as Cli
import qualified Omni.Test as Test
-import System.Directory (doesFileExist)
import qualified System.Console.Docopt as Docopt
+import System.Directory (doesFileExist)
import qualified System.Environment as Environment
import qualified System.Exit as Exit
import qualified System.IO as IO
@@ -115,7 +115,7 @@ run :: Docopt.Arguments -> IO ()
run args = do
-- Parse options
opts <- parseOptions args
-
+
-- Check for dry-run
when (optDryRun opts) <| do
TextIO.hPutStrLn IO.stderr <| "Dry run - would use options:"
@@ -126,10 +126,10 @@ run args = do
TextIO.hPutStrLn IO.stderr <| " Max iter: " <> tshow (optMaxIter opts)
TextIO.hPutStrLn IO.stderr <| " Prompt: " <> Text.take 100 (optPrompt opts)
Exit.exitSuccess
-
+
-- Run agent
result <- runAgent opts
-
+
-- Output result
if optJson opts
then BL.putStr <| AesonPretty.encodePretty result
@@ -185,11 +185,30 @@ instance Aeson.ToJSON AgentResult where
"error" Aeson..= err
]
+-- | Strip shebang line from file content (for executable markdown support)
+stripShebang :: Text -> Text
+stripShebang content =
+ case Text.lines content of
+ (line : rest) | "#!" `Text.isPrefixOf` line -> Text.unlines rest
+ _ -> content
+
-- | Parse CLI options from docopt arguments
parseOptions :: Docopt.Arguments -> IO AgentOptions
parseOptions args = do
- -- Get prompt from arg or stdin (docopt returns String, convert to Text)
- let argPrompt = maybe "" Text.pack <| Docopt.getArg args (Docopt.argument "prompt")
+ -- Get prompt from arg or stdin
+ -- If arg is a file path (e.g., shebang invocation), read file content
+ argPrompt <- case Docopt.getArg args (Docopt.argument "prompt") of
+ Nothing -> pure ""
+ Just arg -> do
+ -- Check if it's a file path (for shebang support: ./script.md)
+ isFile <- doesFileExist arg
+ if isFile && (".md" `isSuffixOf` arg || ".markdown" `isSuffixOf` arg)
+ then do
+ content <- TextIO.readFile arg
+ -- Strip shebang line if present
+ pure <| stripShebang content
+ else pure (Text.pack arg)
+
-- Always read stdin if not a terminal (allows piping data to prompts)
stdinContent <- do
isTerminal <- IO.hIsTerminalDevice IO.stdin
@@ -197,12 +216,10 @@ parseOptions args = do
then pure ""
else TextIO.getContents
-- Combine: if we have stdin, append it to prompt; if no prompt, use stdin
- let prompt =
- if Text.null stdinContent
- then argPrompt
- else if Text.null argPrompt
- then stdinContent
- else argPrompt <> "\n\n--- Input Data ---\n" <> stdinContent
+ let prompt
+ | Text.null stdinContent = argPrompt
+ | Text.null argPrompt = stdinContent
+ | otherwise = argPrompt <> "\n\n--- Input Data ---\n" <> stdinContent
pure
AgentOptions
@@ -301,7 +318,7 @@ resolveAutoProvider mModel = do
-- Try providers in order of preference
anthropicKey <- Environment.lookupEnv "ANTHROPIC_API_KEY"
openrouterKey <- Environment.lookupEnv "OPENROUTER_API_KEY"
-
+
case (anthropicKey, openrouterKey) of
(Just key, _) -> do
TextIO.hPutStrLn IO.stderr "[agent] Using Anthropic provider"