Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
8 changes: 8 additions & 0 deletions src/kimi_cli/agents/default/system.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,14 @@ ${KIMI_WORK_DIR_LS}
```

Use this as your basic understanding of the project structure.
{% if KIMI_ADDITIONAL_DIRS_INFO %}

## Additional Directories

The following directories have been added to the workspace. You can read, write, search, and glob files in these directories as part of your workspace scope.

${KIMI_ADDITIONAL_DIRS_INFO}
{% endif %}

# Project Information

Expand Down
36 changes: 36 additions & 0 deletions src/kimi_cli/cli/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,20 @@ def kimi(
help="Working directory for the agent. Default: current directory.",
),
] = None,
local_add_dirs: Annotated[
list[Path] | None,
typer.Option(
"--add-dir",
exists=True,
file_okay=False,
dir_okay=True,
readable=True,
help=(
"Add an additional directory to the workspace scope. "
"Can be specified multiple times."
),
),
] = None,
session_id: Annotated[
str | None,
typer.Option(
Expand Down Expand Up @@ -485,6 +499,28 @@ async def _run(session_id: str | None) -> tuple[Session, bool]:
session = await Session.create(work_dir)
logger.info("Created new session: {session_id}", session_id=session.id)

# Add CLI-provided additional directories to session state
if local_add_dirs:
from kimi_cli.utils.path import is_within_directory

canonical_work_dir = work_dir.canonical()
changed = False
for d in local_add_dirs:
dir_path = KaosPath.unsafe_from_local_path(d).canonical()
dir_str = str(dir_path)
# Skip dirs within work_dir (already accessible)
if is_within_directory(dir_path, canonical_work_dir):
logger.info(
"Skipping --add-dir {dir}: already within working directory",
dir=dir_str,
)
continue
if dir_str not in session.state.additional_dirs:
session.state.additional_dirs.append(dir_str)
changed = True
if changed:
session.save_state()

instance = await KimiCLI.create(
session,
config=config,
Expand Down
1 change: 1 addition & 0 deletions src/kimi_cli/session_state.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ class SessionState(BaseModel):
version: int = 1
approval: ApprovalStateData = Field(default_factory=ApprovalStateData)
dynamic_subagents: list[DynamicSubagentSpec] = Field(default_factory=_default_dynamic_subagents)
additional_dirs: list[str] = Field(default_factory=list)


def load_session_state(session_dir: Path) -> SessionState:
Expand Down
43 changes: 43 additions & 0 deletions src/kimi_cli/soul/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,8 @@ class BuiltinSystemPromptArgs:
"""The content of AGENTS.md."""
KIMI_SKILLS: str
"""Formatted information about available skills."""
KIMI_ADDITIONAL_DIRS_INFO: str
"""Formatted information about additional directories in the workspace."""


async def load_agents_md(work_dir: KaosPath) -> str | None:
Expand Down Expand Up @@ -74,6 +76,7 @@ class Runtime:
labor_market: LaborMarket
environment: Environment
skills: dict[str, Skill]
additional_dirs: list[KaosPath]

@staticmethod
async def create(
Expand Down Expand Up @@ -104,6 +107,40 @@ async def create(
for skill in skills
)

# Restore additional directories from session state, pruning stale entries
additional_dirs: list[KaosPath] = []
pruned = False
valid_dir_strs: list[str] = []
for dir_str in session.state.additional_dirs:
d = KaosPath(dir_str).canonical()
if await d.is_dir():
additional_dirs.append(d)
valid_dir_strs.append(dir_str)
else:
logger.warning(
"Additional directory no longer exists, removing from state: {dir}",
dir=dir_str,
)
pruned = True
if pruned:
session.state.additional_dirs = valid_dir_strs
session.save_state()

# Format additional dirs info for system prompt
additional_dirs_info = ""
if additional_dirs:
parts: list[str] = []
for d in additional_dirs:
try:
dir_ls = await list_directory(d)
except OSError:
logger.warning(
"Cannot list additional directory, skipping listing: {dir}", dir=d
)
dir_ls = "[directory not readable]"
parts.append(f"### `{d}`\n\n```\n{dir_ls}\n```")
additional_dirs_info = "\n\n".join(parts)

# Merge CLI flag with persisted session state
effective_yolo = yolo or session.state.approval.yolo
saved_actions = set(session.state.approval.auto_approve_actions)
Expand All @@ -130,12 +167,14 @@ def _on_approval_change() -> None:
KIMI_WORK_DIR_LS=ls_output,
KIMI_AGENTS_MD=agents_md or "",
KIMI_SKILLS=skills_formatted or "No skills found.",
KIMI_ADDITIONAL_DIRS_INFO=additional_dirs_info,
),
denwa_renji=DenwaRenji(),
approval=Approval(state=approval_state),
labor_market=LaborMarket(),
environment=environment,
skills=skills_by_name,
additional_dirs=additional_dirs,
)

def copy_for_fixed_subagent(self) -> Runtime:
Expand All @@ -151,6 +190,8 @@ def copy_for_fixed_subagent(self) -> Runtime:
labor_market=LaborMarket(), # fixed subagent has its own LaborMarket
environment=self.environment,
skills=self.skills,
# Share the same list reference so /add-dir mutations propagate to all agents
additional_dirs=self.additional_dirs,
)

def copy_for_dynamic_subagent(self) -> Runtime:
Expand All @@ -166,6 +207,8 @@ def copy_for_dynamic_subagent(self) -> Runtime:
labor_market=self.labor_market, # dynamic subagent shares LaborMarket with main agent
environment=self.environment,
skills=self.skills,
# Share the same list reference so /add-dir mutations propagate to all agents
additional_dirs=self.additional_dirs,
)


Expand Down
77 changes: 77 additions & 0 deletions src/kimi_cli/soul/slash.py
Original file line number Diff line number Diff line change
Expand Up @@ -80,3 +80,80 @@ async def yolo(soul: KimiSoul, args: str):
else:
soul.runtime.approval.set_yolo(True)
wire_send(TextPart(text="You only live once! All actions will be auto-approved."))


@registry.command(name="add-dir")
async def add_dir(soul: KimiSoul, args: str):
"""Add a directory to the workspace.

Usage: /add-dir <path>. Run without args to list added dirs"""
from kaos.path import KaosPath

from kimi_cli.utils.path import is_within_directory, list_directory

args = args.strip()
if not args:
if not soul.runtime.additional_dirs:
wire_send(TextPart(text="No additional directories. Usage: /add-dir <path>"))
else:
lines = ["Additional directories:"]
for d in soul.runtime.additional_dirs:
lines.append(f" - {d}")
wire_send(TextPart(text="\n".join(lines)))
return

path = KaosPath(args).expanduser().canonical()

if not await path.exists():
wire_send(TextPart(text=f"Directory does not exist: {path}"))
return
if not await path.is_dir():
wire_send(TextPart(text=f"Not a directory: {path}"))
return

# Check if already added (exact match)
if path in soul.runtime.additional_dirs:
wire_send(TextPart(text=f"Directory already in workspace: {path}"))
return

# Check if it's within the work_dir (already accessible)
work_dir = soul.runtime.builtin_args.KIMI_WORK_DIR
if is_within_directory(path, work_dir):
wire_send(TextPart(text=f"Directory is already within the working directory: {path}"))
return

# Check if it's within an already-added additional directory (redundant)
for existing in soul.runtime.additional_dirs:
if is_within_directory(path, existing):
wire_send(
TextPart(
text=f"Directory is already within an added directory `{existing}`: {path}"
)
)
return

# Validate readability before committing any state changes
try:
ls_output = await list_directory(path)
except OSError as e:
wire_send(TextPart(text=f"Cannot read directory: {path} ({e})"))
return

# Add the directory (only after readability is confirmed)
soul.runtime.additional_dirs.append(path)

# Persist to session state
soul.runtime.session.state.additional_dirs.append(str(path))
soul.runtime.session.save_state()

# Inject a system message to inform the LLM about the new directory
system_message = system(
f"The user has added an additional directory to the workspace: `{path}`\n\n"
f"Directory listing:\n```\n{ls_output}\n```\n\n"
"You can now read, write, search, and glob files in this directory "
"as if it were part of the working directory."
)
await soul.context.append_message(Message(role="user", content=[system_message]))

wire_send(TextPart(text=f"Added directory to workspace: {path}"))
logger.info("Added additional directory: {path}", path=path)
20 changes: 11 additions & 9 deletions src/kimi_cli/tools/file/glob.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,9 @@
from kosong.tooling import CallableTool2, ToolError, ToolOk, ToolReturnValue
from pydantic import BaseModel, Field

from kimi_cli.soul.agent import BuiltinSystemPromptArgs
from kimi_cli.soul.agent import Runtime
from kimi_cli.tools.utils import load_desc
from kimi_cli.utils.path import is_within_directory, list_directory
from kimi_cli.utils.path import is_within_workspace, list_directory

MAX_MATCHES = 1000

Expand Down Expand Up @@ -38,9 +38,10 @@ class Glob(CallableTool2[Params]):
)
params: type[Params] = Params

def __init__(self, builtin_args: BuiltinSystemPromptArgs) -> None:
def __init__(self, runtime: Runtime) -> None:
super().__init__()
self._work_dir = builtin_args.KIMI_WORK_DIR
self._work_dir = runtime.builtin_args.KIMI_WORK_DIR
self._additional_dirs = runtime.additional_dirs

async def _validate_pattern(self, pattern: str) -> ToolError | None:
"""Validate that the pattern is safe to use."""
Expand All @@ -63,14 +64,15 @@ async def _validate_directory(self, directory: KaosPath) -> ToolError | None:
"""Validate that the directory is safe to search."""
resolved_dir = directory.canonical()

# Ensure the directory is within work directory
if not is_within_directory(resolved_dir, self._work_dir):
# Ensure the directory is within the workspace (work_dir or additional dirs)
if not is_within_workspace(resolved_dir, self._work_dir, self._additional_dirs):
return ToolError(
message=(
f"`{directory}` is outside the working directory. "
"You can only search within the working directory."
f"`{directory}` is outside the workspace. "
"You can only search within the working directory "
"and additional directories."
),
brief="Directory outside working directory",
brief="Directory outside workspace",
)
return None

Expand Down
8 changes: 6 additions & 2 deletions src/kimi_cli/tools/file/read.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
from kimi_cli.soul.agent import Runtime
from kimi_cli.tools.file.utils import MEDIA_SNIFF_BYTES, detect_file_type
from kimi_cli.tools.utils import load_desc, truncate_line
from kimi_cli.utils.path import is_within_directory
from kimi_cli.utils.path import is_within_workspace

MAX_LINES = 1000
MAX_LINE_LENGTH = 2000
Expand Down Expand Up @@ -58,12 +58,16 @@ def __init__(self, runtime: Runtime) -> None:
super().__init__(description=description)
self._runtime = runtime
self._work_dir = runtime.builtin_args.KIMI_WORK_DIR
self._additional_dirs = runtime.additional_dirs

async def _validate_path(self, path: KaosPath) -> ToolError | None:
"""Validate that the path is safe to read."""
resolved_path = path.canonical()

if not is_within_directory(resolved_path, self._work_dir) and not path.is_absolute():
if (
not is_within_workspace(resolved_path, self._work_dir, self._additional_dirs)
and not path.is_absolute()
):
# Outside files can only be read with absolute paths
return ToolError(
message=(
Expand Down
8 changes: 6 additions & 2 deletions src/kimi_cli/tools/file/read_media.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
from kimi_cli.tools.file.utils import MEDIA_SNIFF_BYTES, FileType, detect_file_type
from kimi_cli.tools.utils import load_desc
from kimi_cli.utils.media_tags import wrap_media_part
from kimi_cli.utils.path import is_within_directory
from kimi_cli.utils.path import is_within_workspace
from kimi_cli.wire.types import ImageURLPart, VideoURLPart

MAX_MEDIA_MEGABYTES = 100
Expand Down Expand Up @@ -66,13 +66,17 @@ def __init__(self, runtime: Runtime) -> None:

self._runtime = runtime
self._work_dir = runtime.builtin_args.KIMI_WORK_DIR
self._additional_dirs = runtime.additional_dirs
self._capabilities = capabilities

async def _validate_path(self, path: KaosPath) -> ToolError | None:
"""Validate that the path is safe to read."""
resolved_path = path.canonical()

if not is_within_directory(resolved_path, self._work_dir) and not path.is_absolute():
if (
not is_within_workspace(resolved_path, self._work_dir, self._additional_dirs)
and not path.is_absolute()
):
# Outside files can only be read with absolute paths
return ToolError(
message=(
Expand Down
16 changes: 10 additions & 6 deletions src/kimi_cli/tools/file/replace.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,13 @@
from kosong.tooling import CallableTool2, ToolError, ToolReturnValue
from pydantic import BaseModel, Field

from kimi_cli.soul.agent import BuiltinSystemPromptArgs
from kimi_cli.soul.agent import Runtime
from kimi_cli.soul.approval import Approval
from kimi_cli.tools.display import DisplayBlock
from kimi_cli.tools.file import FileActions
from kimi_cli.tools.utils import ToolRejectedError, load_desc
from kimi_cli.utils.diff import build_diff_blocks
from kimi_cli.utils.path import is_within_directory
from kimi_cli.utils.path import is_within_workspace


class Edit(BaseModel):
Expand Down Expand Up @@ -40,16 +40,20 @@ class StrReplaceFile(CallableTool2[Params]):
description: str = load_desc(Path(__file__).parent / "replace.md")
params: type[Params] = Params

def __init__(self, builtin_args: BuiltinSystemPromptArgs, approval: Approval):
def __init__(self, runtime: Runtime, approval: Approval):
super().__init__()
self._work_dir = builtin_args.KIMI_WORK_DIR
self._work_dir = runtime.builtin_args.KIMI_WORK_DIR
self._additional_dirs = runtime.additional_dirs
self._approval = approval

async def _validate_path(self, path: KaosPath) -> ToolError | None:
"""Validate that the path is safe to edit."""
resolved_path = path.canonical()

if not is_within_directory(resolved_path, self._work_dir) and not path.is_absolute():
if (
not is_within_workspace(resolved_path, self._work_dir, self._additional_dirs)
and not path.is_absolute()
):
return ToolError(
message=(
f"`{path}` is not an absolute path. "
Expand Down Expand Up @@ -115,7 +119,7 @@ async def __call__(self, params: Params) -> ToolReturnValue:

action = (
FileActions.EDIT
if is_within_directory(p, self._work_dir)
if is_within_workspace(p, self._work_dir, self._additional_dirs)
else FileActions.EDIT_OUTSIDE
)

Expand Down
Loading
Loading