Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions lib/ourocode/command/registry/skill_loader.ex
Original file line number Diff line number Diff line change
Expand Up @@ -33,14 +33,20 @@ defmodule Ourocode.Command.Registry.SkillLoader do
end

@spec parse_frontmatter(String.t()) :: map()
def parse_frontmatter("---\n" <> rest) do
def parse_frontmatter(contents) when is_binary(contents) do
contents
|> String.replace("\r\n", "\n")
|> parse_normalized_frontmatter()
end

defp parse_normalized_frontmatter("---\n" <> rest) do
case String.split(rest, "\n---", parts: 2) do
[frontmatter, _body] -> parse_frontmatter_lines(frontmatter)
[_without_closing_marker] -> %{}
end
end

def parse_frontmatter(_contents), do: %{}
defp parse_normalized_frontmatter(_contents), do: %{}

defp normalize!(%{root: root, dir: skill_dir, file: skill_file}, source) do
metadata = skill_file |> File.read!() |> parse_frontmatter()
Expand Down
16 changes: 12 additions & 4 deletions lib/ourocode/config/raw_loader.ex
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ defmodule Ourocode.Config.RawLoader do

@spec discover_config_files(Path.t()) :: [Path.t()]
def discover_config_files(project_dir) when is_binary(project_dir) do
root_dir = Path.expand(project_dir)
root_dir = absolute_path(project_dir)

@supported_config_candidates
|> Enum.map(&Path.join(root_dir, &1))
Expand All @@ -52,7 +52,7 @@ defmodule Ourocode.Config.RawLoader do

@spec load(Path.t()) :: {:ok, RawConfig.t()} | {:error, Ourocode.Config.raw_config_error()}
def load(project_dir) when is_binary(project_dir) do
root_dir = Path.expand(project_dir)
root_dir = absolute_path(project_dir)

Enum.reduce_while(discover_config_files(root_dir), {:ok, []}, fn path, {:ok, entries} ->
case parse_config_file(path, root_dir) do
Expand All @@ -74,10 +74,10 @@ defmodule Ourocode.Config.RawLoader do
@spec parse_config_file(Path.t(), Path.t() | nil) ::
{:ok, RawConfig.file_entry()} | {:error, Ourocode.Config.raw_config_error()}
def parse_config_file(path, root_dir \\ nil) when is_binary(path) do
expanded_path = Path.expand(path)
expanded_path = absolute_path(path)

root_dir =
if is_binary(root_dir), do: Path.expand(root_dir), else: Path.dirname(expanded_path)
if is_binary(root_dir), do: absolute_path(root_dir), else: Path.dirname(expanded_path)

with {:ok, format} <- config_format(expanded_path),
{:ok, contents} <- read_config_file(expanded_path),
Expand Down Expand Up @@ -106,6 +106,14 @@ defmodule Ourocode.Config.RawLoader do
end
end

defp absolute_path(path) do
if Path.type(path) == :absolute do
path
else
Path.expand(path)
end
end

defp config_format(path) do
case path |> Path.basename() |> String.downcase() do
name when name in ["ourocode.json", ".ourocode.json", "config.json"] ->
Expand Down
11 changes: 8 additions & 3 deletions lib/ourocode/plugin/config_validation.ex
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ defmodule Ourocode.Plugin.ConfigValidation do
String.contains?(command, [" ", "\t"]) ->
schema_error("plugins[#{index}].entrypoint.command must not include arguments")

Path.type(command) == :absolute ->
absolute_path?(command) ->
schema_error("plugins[#{index}].entrypoint.command must be a relative command")

path_traverses?(command) ->
Expand Down Expand Up @@ -53,7 +53,7 @@ defmodule Ourocode.Plugin.ConfigValidation do
String.contains?(path, ["\0", "\n", "\r"]) ->
schema_error("plugins[#{index}].#{label} must be a single relative path")

Path.type(path) == :absolute or path_traverses?(path) ->
absolute_path?(path) or path_traverses?(path) ->
schema_error("plugins[#{index}].#{label} must be a relative path inside the plugin")

true ->
Expand All @@ -64,9 +64,14 @@ defmodule Ourocode.Plugin.ConfigValidation do
@spec path_traverses?(String.t()) :: boolean()
def path_traverses?(path) when is_binary(path) do
path
|> Path.split()
|> String.split(["/", "\\"], trim: true)
|> Enum.any?(&(&1 == ".."))
end

defp absolute_path?(path) when is_binary(path) do
Path.type(path) == :absolute or String.starts_with?(path, ["/", "\\\\"]) or
Regex.match?(~r/^[A-Za-z]:[\/\\]/, path)
end

defp schema_error(message), do: {:error, {:invalid_plugin_config_schema, message}}
end
58 changes: 30 additions & 28 deletions lib/ourocode/plugin/path_policy.ex
Original file line number Diff line number Diff line change
Expand Up @@ -69,40 +69,42 @@ defmodule Ourocode.Plugin.PathPolicy do
end

defp canonical_path(path) do
expanded_path = Path.expand(path)
parts = Path.split(expanded_path)
path
|> Path.expand()
|> Path.split()
|> resolve_existing_segments()
end

case deepest_existing_prefix(parts) do
{existing_parts, remaining_parts} ->
Path.join([realpath(Path.join(existing_parts)) | remaining_parts])
defp resolve_existing_segments([]), do: ""
defp resolve_existing_segments([root | parts]), do: resolve_existing_segments(root, parts)

nil ->
expanded_path
end
end
defp resolve_existing_segments(path, []), do: path

defp realpath(path) do
case System.find_executable("realpath") do
nil ->
path
defp resolve_existing_segments(path, [part | remaining_parts]) do
candidate = Path.join(path, part)

executable ->
case System.cmd(executable, [path], stderr_to_stdout: true) do
{resolved, 0} -> String.trim_trailing(resolved)
{_output, _status} -> path
end
if File.exists?(candidate) do
candidate
|> resolve_link()
|> resolve_existing_segments(remaining_parts)
else
Path.join([candidate | remaining_parts])
end
end

defp deepest_existing_prefix(parts) do
Enum.reduce_while(length(parts)..1//-1, nil, fn count, _acc ->
existing_parts = Enum.take(parts, count)

if File.exists?(Path.join(existing_parts)) do
{:halt, {existing_parts, Enum.drop(parts, count)}}
else
{:cont, nil}
end
end)
defp resolve_link(path) do
charlist_path = String.to_charlist(path)

with {:ok,
{:file_info, _size, :symlink, _access, _atime, _mtime, _ctime, _mode, _links,
_major_device, _minor_device, _inode, _uid, _gid}} <-
:file.read_link_info(charlist_path),
{:ok, target} <- :file.read_link(charlist_path) do
target
|> List.to_string()
|> Path.expand(Path.dirname(path))
else
_ -> path
end
end
end
8 changes: 6 additions & 2 deletions lib/ourocode/runtime/seed_artifact.ex
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,11 @@ defmodule Ourocode.Runtime.SeedArtifact do
def extract_yaml(text) when is_binary(text) do
case String.split(text, @seed_marker, parts: 2) do
[_before, yaml] ->
yaml = String.trim(yaml)
yaml =
yaml
|> String.replace("\r\n", "\n")
|> String.trim()

if yaml == "", do: :ignore, else: {:ok, yaml}

_other ->
Expand All @@ -53,7 +57,7 @@ defmodule Ourocode.Runtime.SeedArtifact do
@spec write(String.t(), String.t(), String.t()) :: {:ok, String.t()} | {:error, term()}
def write(cwd, seed_id, seed_yaml)
when is_binary(cwd) and is_binary(seed_id) and is_binary(seed_yaml) do
path = Path.join(Path.expand(cwd), seed_id <> ".yaml")
path = Path.join(cwd, seed_id <> ".yaml")

case File.write(path, seed_yaml <> "\n") do
:ok -> {:ok, path}
Expand Down
1 change: 1 addition & 0 deletions lib/ourocode/terminal/file_discovery.ex
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ defmodule Ourocode.Terminal.FileDiscovery do
def parse_rg_files(out) when is_binary(out) do
out
|> String.split("\n", trim: true)
|> Enum.map(&String.trim_trailing(&1, "\r"))
|> Enum.reject(&ignored_path?/1)
|> Enum.take(@limit)
end
Expand Down
1 change: 1 addition & 0 deletions lib/ourocode/terminal/frame_sections.ex
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ defmodule Ourocode.Terminal.FrameSections do
def parse(frame) when is_binary(frame) do
frame
|> String.split("\n")
|> Enum.map(&String.trim_trailing(&1, "\r"))
|> Enum.reduce({[], nil}, fn line, {sections, current} ->
cond do
String.starts_with?(line, "+-- ") ->
Expand Down
4 changes: 3 additions & 1 deletion lib/ourocode/terminal/interview_handoff.ex
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ defmodule Ourocode.Terminal.InterviewHandoff do

@spec prompt(String.t(), String.t()) :: String.t()
def prompt(question, user_message) do
"""
prompt = """
You are the main ourocode session. An interview checkpoint is paused so
the user can discuss it with you before answering.

Expand All @@ -21,6 +21,8 @@ defmodule Ourocode.Terminal.InterviewHandoff do
Do not include that line for clarifications, translations, explanations,
or ordinary discussion.
"""

String.replace(prompt, "\r\n", "\n")
end

@spec extract_answer(term()) :: String.t() | nil
Expand Down
21 changes: 20 additions & 1 deletion lib/ourocode/terminal/interview_panel/text.ex
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ defmodule Ourocode.Terminal.InterviewPanel.Text do
def md_text(text) do
text
|> to_string()
|> scrub_invalid_utf8()
|> String.replace(~r/(\*\*|__)(.*?)\1/s, "\\2")
|> String.replace(~r/(\*|_)(.*?)\1/s, "\\2")
|> String.replace(~r/`([^`]+)`/, "\\1")
Expand All @@ -55,6 +56,7 @@ defmodule Ourocode.Terminal.InterviewPanel.Text do
def plain_line(text) do
text
|> to_string()
|> scrub_invalid_utf8()
|> repair_hangul_syllable_spacing()
|> String.replace(~r/\s+/, " ")
|> String.trim()
Expand All @@ -63,7 +65,24 @@ defmodule Ourocode.Terminal.InterviewPanel.Text do
defp strip_unstable_glyphs(text) do
text
|> String.replace(~r/[\x{FFFD}\x{FE0E}\x{FE0F}\x{200D}]/u, "")
|> String.replace(~r/[\x{1F000}-\x{1FAFF}]/u, "")
|> strip_supplemental_symbols()
end

defp scrub_invalid_utf8(text) do
String.replace_invalid(text, "")
end

defp strip_supplemental_symbols(text) do
text
|> String.graphemes()
|> Enum.reject(&supplemental_symbol?/1)
|> Enum.join()
end

defp supplemental_symbol?(grapheme) do
grapheme
|> String.to_charlist()
|> Enum.any?(&(&1 in 0x1F000..0x1FAFF))
end

defp repair_hangul_syllable_spacing(text) do
Expand Down
7 changes: 4 additions & 3 deletions test/ourocode/cli_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ defmodule Ourocode.CLITest do
alias Ourocode.CLI.SmokeTest

import ExUnit.CaptureIO
import Ourocode.Test.PathAssertions, only: [assert_same_path: 2]

test "startup resolves the current working directory by default" do
assert Ourocode.CLI.resolve_project_dir() == {:ok, File.cwd!()}
Expand Down Expand Up @@ -181,11 +182,11 @@ defmodule Ourocode.CLITest do
test "launch prints version without bootstrapping the terminal UI" do
output =
capture_io(fn ->
assert {:ok, %{mode: :version, version: "0.1.13"}} =
assert {:ok, %{mode: :version, version: "0.1.14"}} =
Ourocode.CLI.launch(["--version"], Ourocode.CLITest.TerminalBootstrapSpy)
end)

assert output == "ourocode 0.1.13\n"
assert output == "ourocode 0.1.14\n"
refute_receive {:terminal_bootstrap, _context}, 50
end

Expand Down Expand Up @@ -341,7 +342,7 @@ defmodule Ourocode.CLITest do
)

assert_receive {:terminal_bootstrap, ^context}
assert context.project_dir == project_dir
assert_same_path(context.project_dir, project_dir)
assert context.plugin_config.plugins |> Enum.map(& &1.id) == ["ouroboros-plugin"]
after
Application.delete_env(:ourocode, :cli_test_pid)
Expand Down
47 changes: 27 additions & 20 deletions test/ourocode/command/registry/skill_loader_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -15,36 +15,43 @@ defmodule Ourocode.Command.Registry.SkillLoaderTest do
assert SkillLoader.discover_files(root) == [
%{
root: Path.expand(root),
dir: Path.join(root, "alpha"),
file: Path.join([root, "alpha", "SKILL.md"])
dir: Path.expand(Path.join(root, "alpha")),
file: Path.expand(Path.join([root, "alpha", "SKILL.md"]))
},
%{
root: Path.expand(root),
dir: Path.join(root, "beta"),
file: Path.join([root, "beta", "SKILL.md"])
dir: Path.expand(Path.join(root, "beta")),
file: Path.expand(Path.join([root, "beta", "SKILL.md"]))
}
]
after
cleanup_tmp_dir()
end

test "parses simple scalar frontmatter and ignores comments or malformed lines" do
assert SkillLoader.parse_frontmatter("""
---
name: "ship-it"
description: Prepare: release checklist
# ignored: true

malformed
mcp_tool: release_planner
---

# Body
""") == %{
"name" => "\"ship-it\"",
"description" => "Prepare: release checklist",
"mcp_tool" => "release_planner"
}
lf_frontmatter = """
---
name: "ship-it"
description: Prepare: release checklist
# ignored: true

malformed
mcp_tool: release_planner
---

# Body
"""

expected = %{
"name" => "\"ship-it\"",
"description" => "Prepare: release checklist",
"mcp_tool" => "release_planner"
}

assert SkillLoader.parse_frontmatter(lf_frontmatter) == expected

crlf_frontmatter = String.replace(lf_frontmatter, "\n", "\r\n")
assert SkillLoader.parse_frontmatter(crlf_frontmatter) == expected

assert SkillLoader.parse_frontmatter("# No frontmatter") == %{}
assert SkillLoader.parse_frontmatter("---\nname: missing-close") == %{}
Expand Down
24 changes: 24 additions & 0 deletions test/ourocode/config/raw_loader_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,30 @@ defmodule Ourocode.Config.RawLoaderTest do
File.rm_rf!(Process.get(:raw_loader_tmp_dir))
end

test "preserves Windows path spelling while parsing CRLF config files with portable relative paths" do
dir = unique_tmp_dir("windows-paths")
File.mkdir_p!(Path.join(dir, ".ourocode"))
config_path = Path.join(dir, ".ourocode/config.json")

File.write!(
config_path,
"{\r\n \"runtime\": {\r\n \"repeat-count\": 3\r\n }\r\n}\r\n"
)

assert {:ok, raw} = RawLoader.load(dir)
assert raw.root_dir == dir

assert [
%{
path: ^config_path,
relative_path: ".ourocode/config.json",
data: %{"runtime" => %{"repeat_count" => 3}}
}
] = raw.files
after
File.rm_rf!(Process.get(:raw_loader_tmp_dir))
end

test "projects only supported runtime override keys from raw config" do
raw = %{
"runtime" => %{
Expand Down
Loading