mini-code-cli 0.0.8.4__py3-none-any.whl → 0.1.0.1__py3-none-any.whl

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.
@@ -16,6 +16,7 @@ smaller, focused modules so each concern is easy to read and test:
16
16
  * :mod:`mini_code_cli.runner` -- running a tool with a wall-clock timeout.
17
17
  * :mod:`mini_code_cli.config` -- shared default settings.
18
18
  * :mod:`mini_code_cli.subagents` -- delegating a task to a sub-agent.
19
+ * :mod:`mini_code_cli.skills` -- the ``.mini`` skills feature.
19
20
 
20
21
  For backward compatibility (and the test-suite) the most commonly imported
21
22
  helpers are re-exported from here, so ``from mini_code_cli.agent_loop import ...``
@@ -32,6 +33,7 @@ from . import tools
32
33
  from . import utils
33
34
  from . import ui
34
35
  from . import subagents
36
+ from . import skills
35
37
  from . import plan as plan_utils
36
38
  from .config import (
37
39
  DEFAULT_CACHE_LOCATION,
@@ -116,14 +118,29 @@ def parse_args():
116
118
  parser.add_argument("--allowed-dir", type=str, default=".", help="Allowed directory for file operations, default='./'")
117
119
  parser.add_argument("-s", "--enable-shell", action="store_true", help="Allow shell execution. default= False")
118
120
  parser.add_argument("-g","--enable-subagents", action="store_true", help="Allow the agent to delegate work to sub-agents (exposed as extra tools). default= False")
121
+ parser.add_argument("--skills", action="store_true", help="Enable the skills feature: a local .mini directory (inside --allowed-dir) of skill.md files, skill sub-dirs, a high-level SKILLS.md summary, and executable Python scripts, exposed to the agent as list_skills/read_skill/create_skill/run_skill tools. default= False")
119
122
  parser.add_argument("--llm-timeout", type=int, default=DEFAULT_HTTP_TIMEOUT, help=f"HTTP timeout in seconds for LLM requests (reasoning models can be slow). default={DEFAULT_HTTP_TIMEOUT}")
120
123
  parser.add_argument("--tool-timeout", type=int, default=DEFAULT_TOOL_TIMEOUT, help=f"Timeout in seconds for a single tool call. default={DEFAULT_TOOL_TIMEOUT}")
121
124
  parser.add_argument("-p","--plan", action="store_true", help="Enable plan mode: the agent keeps a CURRENT_PLAN.md (its step-by-step plan, marked finished when the goal is done) and CURRENT_GOAL.md (the user's latest instruction) in the session directory. Also changes how history compaction works.")
122
125
 
126
+ parser.add_argument("--all", action="store_true", help="Enable: --plan, --subagents, --shell, --automode, --sudo")
127
+ parser.add_argument("--all-last", action="store_true", help="Enable: --plan, --subagents, --shell, --automode, --sudo, --load-last-session")
128
+
123
129
  # Output verbosity
124
130
  parser.add_argument("--verbose", action="store_true", help="Print the full, detailed output. Without it (default), only user/assistant turns, token usage and tool calls are shown.")
125
131
 
126
132
  args = parser.parse_args()
133
+
134
+ if args.all or args.all_last:
135
+ args.plan = True
136
+ args.enable_subagents = True
137
+ args.enable_shell = True
138
+ args.sudo = True
139
+ args.auto_mode = True
140
+
141
+ if args.all_last:
142
+ args.load_last_session = True
143
+
127
144
  return args
128
145
 
129
146
 
@@ -154,6 +171,10 @@ def print_help(allowed_dir, server_url, args, unique_id, cache_dir, model_name):
154
171
  ui.console.emit(ui.console.style("Sub-agents:", bold=True, fg="cyan"))
155
172
  for sa in subagents.get_registry().values():
156
173
  ui.console.emit(f" {sa.name:16} {sa.description}")
174
+ if args.enable_skills or args.verbose:
175
+ ui.console.emit(ui.console.style("Skills:", bold=True, fg="cyan"))
176
+ for name in skills.SKILL_TOOL_NAMES:
177
+ ui.console.emit(f" {name:16} (from the local .mini directory)")
157
178
  ui.console.rule(char="═", fg="cyan")
158
179
 
159
180
 
@@ -285,6 +306,19 @@ class AgentSession:
285
306
  # real function.
286
307
  self.tools_dict[sa.name] = {"function": None, "description": sa.description, "subagent": True}
287
308
 
309
+ # Skills (opt-in via --skills). Each registered skill tool is exposed to
310
+ # the model as a normal tool, but they are all sandbox-safe: the .mini
311
+ # directory lives *inside* --allowed-dir and the tools never accept a
312
+ # path from the model -- skill / script names are validated as single
313
+ # path segments and resolved under the .mini root. So skills are exposed
314
+ # even in sessions without --enable-shell (run_skill only executes a
315
+ # named Python file, never a free-form shell command).
316
+ self.enable_skills = bool(getattr(args, "enable_skills", False))
317
+ if self.enable_skills:
318
+ self.skills_tools_dict = skills.get_skills_tools_dict(self.allowed_dir)
319
+ self.llm_tools.extend(skills.get_skills_tools(self.allowed_dir))
320
+ self.tools_dict.update(self.skills_tools_dict)
321
+
288
322
  # Token accounting for the whole session.
289
323
  self.token_stats = {
290
324
  "prompt_tokens": 0,
@@ -363,7 +397,11 @@ class AgentSession:
363
397
  if self.args.system_prompt:
364
398
  system_prompt = self.args.system_prompt
365
399
  else:
366
- system_prompt = prompts.system_prompt(tools_dict=self.tools_dict, allowed_dir=self.allowed_dir, plan_mode=self.plan_mode)
400
+ system_prompt = prompts.system_prompt(
401
+ tools_dict=self.tools_dict, allowed_dir=self.allowed_dir,
402
+ plan_mode=self.plan_mode,
403
+ skills_dir=self.allowed_dir if self.enable_skills else None,
404
+ )
367
405
 
368
406
  messages = [{"role": "system", "content": system_prompt}]
369
407
 
@@ -543,6 +581,10 @@ class AgentSession:
543
581
  print_welcome(self.allowed_dir, self.server_url, self.args,
544
582
  self.unique_id, self.cache_dir, self.model)
545
583
 
584
+ # Announce the session's unique id as an assistant-styled message so the
585
+ # user can see / copy the id this conversation is being saved under.
586
+ self._print_session_id()
587
+
546
588
  # Report the outcome of --load-last-session up front, so the user sees
547
589
  # whether we resumed an earlier conversation (under a brand-new id) or
548
590
  # started fresh because no previous session was found.
@@ -611,6 +653,20 @@ class AgentSession:
611
653
  # Ctrl-C mid-loop just saves and re-prompts rather than crashing.
612
654
  self._persist()
613
655
 
656
+ def _print_session_id(self):
657
+ """Print the session's unique id as an assistant-styled message.
658
+
659
+ A short, styled announcement (in the assistant accent) so the user can
660
+ see and copy the id the conversation is saved under -- the same id shown
661
+ in the welcome banner, but rendered as a transcript turn so it is easy
662
+ to spot and grep.
663
+ """
664
+ self.console.divider()
665
+ self.console.turn(
666
+ "Session id",
667
+ f"{self.unique_id} (saved under {self.cache_dir})",
668
+ accent="green")
669
+
614
670
  # -- autonomous drive (used by sub-agents) ------------------------------
615
671
 
616
672
  def _drive_auto(self, initial_task, max_steps=None):
@@ -619,20 +675,39 @@ class AgentSession:
619
675
  Appends ``initial_task`` as a user turn, then keeps stepping until the
620
676
  model makes no further tool calls (auto-mode). ``max_steps`` caps the
621
677
  number of model calls so a misbehaving sub-agent cannot loop forever.
678
+
679
+ The sub-agent records its work in its own cache dir: every step persists
680
+ history / recorder / usage into the child's own session folder, and it
681
+ seeds CURRENT_GOAL.md / CURRENT_PLAN.md (in plan mode) so each sub-session
682
+ is self-contained and easy to inspect or resume. A final record is left
683
+ even on a mid-run failure (the seeded goal is marked finished).
622
684
  """
623
685
  self.messages.append({"role": "user", "content": initial_task})
686
+ # Seed the goal up front so the child keeps a self-contained record in
687
+ # its own cache dir (a sub-agent always records a goal, independent of
688
+ # the parent's --plan flag). We use the child's own plan_manager here so
689
+ # nothing is written to the parent's session folder.
690
+ from .plan import PlanManager
691
+ child_plan = PlanManager(os.path.join(self.cache_dir, self.unique_id), enabled=True)
692
+ child_plan.write_goal(initial_task)
624
693
  steps = 0
625
- while True:
626
- should_continue = self._step()
694
+ try:
695
+ while True:
696
+ should_continue = self._step()
697
+ self._persist()
698
+ steps += 1
699
+ if max_steps and steps >= max_steps:
700
+ self.console.note(
701
+ f"Sub-agent reached max_steps ({max_steps}); stopping.", "warn")
702
+ break
703
+ if not should_continue:
704
+ break
705
+ return self._last_assistant_text()
706
+ finally:
707
+ # Leave a clean, finished record in the child's cache dir even on an
708
+ # error / early stop, so the sub-session is never left open.
709
+ child_plan.mark_finished()
627
710
  self._persist()
628
- steps += 1
629
- if max_steps and steps >= max_steps:
630
- self.console.note(
631
- f"Sub-agent reached max_steps ({max_steps}); stopping.", "warn")
632
- break
633
- if not should_continue:
634
- break
635
- return self._last_assistant_text()
636
711
 
637
712
  def _last_assistant_text(self):
638
713
  """Return the content of the most recent assistant (non tool-call) turn."""
@@ -807,7 +882,7 @@ class AgentSession:
807
882
  self.unique_id, self.cache_dir, self.model)
808
883
  return True
809
884
  if cmd in self.CLEAR_COMMANDS:
810
- self.console.clear_screen()
885
+ self._clear_and_show_last()
811
886
  return True
812
887
  self.console.note(f"Unknown command {stripped!r}. Try /help.", "warn")
813
888
  return True
@@ -835,12 +910,42 @@ class AgentSession:
835
910
  return
836
911
  self._reset_history(target["turn"], target)
837
912
 
913
+ def _clear_and_show_last(self):
914
+ """Handle /clear: wipe the screen and re-print the last turn.
915
+
916
+ The conversation itself is unchanged; we just redraw the most recent
917
+ exchange so the user re-orients after clearing the view. If there is no
918
+ assistant reply yet, the last user turn is shown instead.
919
+ """
920
+ self.console.clear_screen()
921
+ last_role = last_text = None
922
+ for m in reversed(self.messages):
923
+ role = m.get("role")
924
+ content = m.get("content")
925
+ if role == "assistant" and not m.get("tool_calls") and content:
926
+ last_role, last_text = "Assistant", content
927
+ break
928
+ if role == "user" and content:
929
+ last_role, last_text = "You", content
930
+ break
931
+ if last_role is not None:
932
+ self.console.divider()
933
+ accent = "green" if last_role == "Assistant" else "cyan"
934
+ self.console.turn(last_role, last_text, accent=accent)
935
+
838
936
  # -- tool execution ----------------------------------------------------
839
937
 
840
938
  def _run_tool_calls(self, tool_calls):
841
939
  _make_tool_call_note(self.console, len(tool_calls))
842
- for call in tool_calls:
843
- self._run_one_tool_call(call)
940
+ # Show a "… calling tool" spinner (in-place, non-scrolling) while the
941
+ # tool(s) execute, mirroring the thinking spinner that covers the LLM
942
+ # call. Always stopped (finally) so it can never leak into the next turn.
943
+ self.console.start_tool_call()
944
+ try:
945
+ for call in tool_calls:
946
+ self._run_one_tool_call(call)
947
+ finally:
948
+ self.console.stop_tool_call()
844
949
 
845
950
  def _run_one_tool_call(self, call):
846
951
  call_id = call.get("id", "")
mini_code_cli/plan.py CHANGED
@@ -6,14 +6,16 @@ active goal and the plan for reaching it:
6
6
 
7
7
  * ``CURRENT_GOAL.md`` -- the user's latest instruction / goal (verbatim).
8
8
  * ``CURRENT_PLAN.md`` -- the agent's current plan for that goal, followed by
9
- a short running log (e.g. an entry recording when a goal was finished).
9
+ a short running log (e.g. an entry recording when a goal was set/changed,
10
+ and when it was finished).
10
11
 
11
12
  The plan document itself is authored by the agent (the system prompt tells it
12
13
  to, and it uses its ``write_file`` tool). This manager owns the parts the loop
13
14
  is responsible for -- seeding/reading the goal, reading the plan back so history
14
- compaction can re-inject it, and appending the "goal finished" log entry. It is
15
- deliberately free of any network or terminal dependency, and every method is a
16
- no-op when disabled, so it is trivial to unit-test in isolation.
15
+ compaction can re-inject it, and appending the "goal finished" / goal-change log
16
+ entries. It is deliberately free of any network or terminal dependency, and
17
+ every method is a no-op when disabled, so it is trivial to unit-test in
18
+ isolation.
17
19
  """
18
20
 
19
21
  import os
@@ -31,6 +33,11 @@ class PlanManager:
31
33
  ``history_{n}.json``). When ``enabled`` is False every method is a no-op, so
32
34
  callers can use the manager unconditionally and let the ``--plan`` flag
33
35
  decide whether anything actually happens.
36
+
37
+ ``session_dir`` is the session directory (``cache_dir/<unique_id>``) -- i.e.
38
+ the files always live in the *cache* dir, independent of ``--allowed-dir``.
39
+ The manager writes with plain ``open`` (not through the file tools), so the
40
+ records are created even when the allowed dir is a different location.
34
41
  """
35
42
 
36
43
  def __init__(self, session_dir, enabled=True, goal=None):
@@ -39,6 +46,10 @@ class PlanManager:
39
46
  # The last goal this manager was asked to record, so callers can tell a
40
47
  # new goal apart from the previous one. Kept in sync by write_goal().
41
48
  self.goal = goal
49
+ # The last goal (stripped) that was logged into CURRENT_PLAN.md, so
50
+ # write_goal can tell a genuine change apart from a re-stated goal and
51
+ # avoid spamming the plan with duplicate "goal updated" entries.
52
+ self._last_logged_goal = None
42
53
 
43
54
  # -- paths -------------------------------------------------------------
44
55
  @property
@@ -50,20 +61,55 @@ class PlanManager:
50
61
  return os.path.join(self.session_dir, GOAL_FILENAME)
51
62
 
52
63
  # -- writing -----------------------------------------------------------
64
+ def _append_plan_note(self, text):
65
+ """Append one dated line to CURRENT_PLAN.md (creating it if needed).
66
+
67
+ Shared by the goal-change log and ``mark_finished``. Best-effort and
68
+ never raises: a plan-tracking hiccup must never break the agent loop.
69
+ """
70
+ if not self.enabled:
71
+ return
72
+ try:
73
+ os.makedirs(self.session_dir, exist_ok=True)
74
+ if not os.path.exists(self.plan_path):
75
+ with open(self.plan_path, "w", encoding="utf-8") as f:
76
+ f.write("# Current Plan\n\n")
77
+ with open(self.plan_path, "a", encoding="utf-8") as f:
78
+ f.write(text + "\n")
79
+ except OSError:
80
+ pass
81
+
53
82
  def write_goal(self, goal):
54
83
  """Persist the user's current goal / last instruction to CURRENT_GOAL.md.
55
84
 
56
85
  Overwrites any previous goal -- the file always reflects the *latest*
57
- instruction. Best-effort and never raises: a plan-tracking hiccup must
58
- never break the agent loop.
86
+ instruction. Also makes sure ``CURRENT_PLAN.md`` exists in the cache dir
87
+ (so both tracking files are present from the very first goal), and -- in
88
+ plan mode -- logs the goal into CURRENT_PLAN.md the *first* time it is
89
+ set and again whenever it changes, so the plan records goal pivots.
90
+ Best-effort and never raises.
59
91
  """
60
92
  if not self.enabled:
61
93
  return
94
+ stripped = (goal or "").strip()
62
95
  try:
63
96
  os.makedirs(self.session_dir, exist_ok=True)
97
+ # Ensure CURRENT_PLAN.md exists in the cache dir too, so the goal
98
+ # always has a companion plan file sitting next to it.
99
+ if not os.path.exists(self.plan_path):
100
+ with open(self.plan_path, "w", encoding="utf-8") as f:
101
+ f.write("# Current Plan\n\n")
64
102
  with open(self.goal_path, "w", encoding="utf-8") as f:
65
103
  f.write("# Current Goal\n\n")
66
- f.write((goal or "").strip() + "\n")
104
+ f.write(stripped + "\n")
105
+ # Log the goal change into the plan -- but only on a genuine change
106
+ # (compared against the last goal actually logged), so re-stating the
107
+ # same goal on later turns does not spam the plan log.
108
+ if stripped and stripped != self._last_logged_goal:
109
+ verb = "Goal set to" if self._last_logged_goal is None else "Goal updated to"
110
+ stamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
111
+ self._append_plan_note(f"- [{stamp}] {verb}: {stripped}")
112
+ self._last_logged_goal = stripped
67
113
  self.goal = goal
68
114
  except OSError:
69
115
  pass
@@ -82,16 +128,8 @@ class PlanManager:
82
128
  # never captured one itself, e.g. a one-shot --prompt preset), and only
83
129
  # then fall back to a generic description.
84
130
  goal = goal or self.goal or self._read(self.goal_path) or "current goal"
85
- try:
86
- os.makedirs(self.session_dir, exist_ok=True)
87
- if not os.path.exists(self.plan_path):
88
- with open(self.plan_path, "w", encoding="utf-8") as f:
89
- f.write("# Current Plan\n\n")
90
- stamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
91
- with open(self.plan_path, "a", encoding="utf-8") as f:
92
- f.write(f"\n- [{stamp}] Task finished: {goal}\n")
93
- except OSError:
94
- pass
131
+ stamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
132
+ self._append_plan_note(f"- [{stamp}] Task finished: {goal}")
95
133
 
96
134
  # -- reading -----------------------------------------------------------
97
135
  def read_plan(self):
mini_code_cli/prompts.py CHANGED
@@ -1,5 +1,5 @@
1
1
  # System prompt defining available tools and compaction behavior
2
- def system_prompt(tools_dict=None, allowed_dir=None, plan_mode=False):
2
+ def system_prompt(tools_dict=None, allowed_dir=None, plan_mode=False, skills_dir=None):
3
3
  """
4
4
  Takes a list of tool names and returns a system prompt string.
5
5
 
@@ -26,6 +26,10 @@ def system_prompt(tools_dict=None, allowed_dir=None, plan_mode=False):
26
26
  if allowed_dir:
27
27
  allowed_dir_prompt = f"\nThe path you are allowed to operate in is called: {allowed_dir}"
28
28
 
29
+ skills_prompt = ""
30
+ if skills_dir is not None:
31
+ skills_prompt = "\n" + skills_instructions(skills_dir)
32
+
29
33
  plan_mode_prompt = ""
30
34
  if plan_mode:
31
35
  plan_mode_prompt = "\n" + plan_instructions()
@@ -33,7 +37,7 @@ def system_prompt(tools_dict=None, allowed_dir=None, plan_mode=False):
33
37
 
34
38
  prompt = f"""
35
39
  {base_prompt}
36
- {tool_descriptions}{end_prompt}{allowed_dir_prompt}{plan_mode_prompt}
40
+ {tool_descriptions}{end_prompt}{allowed_dir_prompt}{plan_mode_prompt}{skills_prompt}
37
41
  Important: Follow the user instruction. Do not create additional .md files (unless asked to do so). Do not create unit tests (unless asked to do so), etc.
38
42
  """
39
43
  return prompt.strip()
@@ -62,6 +66,36 @@ def plan_instructions():
62
66
  "easy to track and resume."
63
67
  )
64
68
 
69
+ def skills_instructions(skills_dir):
70
+ """The extra system-prompt block when the skills feature is enabled.
71
+
72
+ Tells the agent it has a local ``.mini`` directory of reusable skills and
73
+ explains the four skill tools (see :mod:`mini_code_cli.skills`). ``skills_dir``
74
+ is the actual ``.mini`` path so the prompt can point at it concretely.
75
+ """
76
+ from . import skills as skills_mod
77
+ return (
78
+ "SKILLS MODE: You can work with reusable skills kept in the local "
79
+ f"`.mini` directory at: {skills_mod.skills_dir(skills_dir)}.\n"
80
+ "Each skill is a sub-directory containing a `skill.md` (what the skill is "
81
+ "/ how to use it) and optional executable Python helper scripts. There is "
82
+ "also a high-level `SKILLS.md` summary of all skills.\n"
83
+ "Use these tools to work with skills:\n"
84
+ " * `list_skills` - see which skills exist (name + one-line summary).\n"
85
+ " * `read_skill` - read a skill's `skill.md` by name, to learn how to "
86
+ "use it or which scripts it provides.\n"
87
+ " * `create_skill` - create a new skill (its sub-directory and `skill.md`) "
88
+ "when you build a reusable capability. Give a clear, single-segment name "
89
+ "and a short summary. It is normal to then add Python helper scripts into "
90
+ "the skill's directory.\n"
91
+ " * `run_skill` - run one of a skill's Python scripts, passing any "
92
+ "command-line arguments via its `args`. Read the skill first to know "
93
+ "which script to run and what arguments it expects.\n"
94
+ "Prefer turning reusable helpers into skills (with a `skill.md` plus "
95
+ "scripts) so they can be reused later, rather than repeating one-off work.\n"
96
+ )
97
+
98
+
65
99
  def compaction_prompt(messages):
66
100
  """
67
101
  Takes a messages list and returns a compact prompt string summarizing the conversation.
@@ -0,0 +1,365 @@
1
+ """mini_code_cli.skills -- the ``.mini`` *skills* feature.
2
+
3
+ A *skill* is a small, reusable, self-describing capability the agent can load on
4
+ demand, kept in a local ``.mini`` directory inside the allowed dir:
5
+
6
+ <allowed_dir>/.mini/
7
+ SKILLS.md # high-level summary of every skill
8
+ <skill-name>/
9
+ skill.md # what the skill is / how to use it
10
+ <script>.py # optional executable Python helper(s)
11
+
12
+ The agent "works with skills" through four tools (only present when ``--skills``
13
+ is enabled):
14
+
15
+ * ``list_skills`` -- what skills exist (names + one-line summaries).
16
+ * ``read_skill`` -- read a single skill's ``skill.md`` by name.
17
+ * ``create_skill`` -- create a new skill (its dir + ``skill.md``); this also
18
+ refreshes the high-level ``SKILLS.md`` summary.
19
+ * ``run_skill`` -- execute one of a skill's Python scripts, with optional
20
+ command-line args.
21
+
22
+ Names are sandboxed *by construction*: skill / script names are validated to be
23
+ a single safe path segment and then joined under the known ``.mini`` root inside
24
+ the allowed dir, so a skill tool can never escape ``--allowed-dir``. A final
25
+ ``realpath`` containment check enforces that anyway.
26
+ """
27
+
28
+ import os
29
+ import sys
30
+ import shlex
31
+ import shutil
32
+ import subprocess
33
+ import functools
34
+
35
+ # Default timeout (seconds) for a script run via run_skill.
36
+ DEFAULT_TOOL_TIMEOUT = 120
37
+
38
+ # Layout constants. Keeping them here (and re-exported) means prompts.py and the
39
+ # agent loop can refer to the same names rather than hard-coding them twice.
40
+ SKILLS_DIRNAME = ".mini"
41
+ SUMMARY_FILENAME = "SKILLS.md"
42
+ SKILL_FILENAME = "skill.md"
43
+ SCRIPT_SUFFIX = ".py"
44
+
45
+ # The tool names this feature contributes to the agent (used by the loop to
46
+ # register them and by the sandbox/help wiring).
47
+ SKILL_TOOL_NAMES = ("list_skills", "read_skill", "create_skill", "run_skill")
48
+
49
+
50
+ # -- paths -----------------------------------------------------------------
51
+ def skills_dir(allowed_dir):
52
+ """The ``.mini`` directory for ``allowed_dir``."""
53
+ return os.path.join(allowed_dir, SKILLS_DIRNAME)
54
+
55
+
56
+ def summary_path(allowed_dir):
57
+ """Path to the high-level ``SKILLS.md`` summary."""
58
+ return os.path.join(skills_dir(allowed_dir), SUMMARY_FILENAME)
59
+
60
+
61
+ def skill_dir(allowed_dir, skill_name):
62
+ """Path to a single skill's sub-directory."""
63
+ return os.path.join(skills_dir(allowed_dir), skill_name)
64
+
65
+
66
+ def skill_md_path(allowed_dir, skill_name):
67
+ """Path to a skill's ``skill.md``."""
68
+ return os.path.join(skill_dir(allowed_dir, skill_name), SKILL_FILENAME)
69
+
70
+
71
+ # -- name safety -----------------------------------------------------------
72
+ def _safe_name(name, what="name"):
73
+ """Return ``name`` if it is a safe single path segment, else ``None``.
74
+
75
+ Rejects empty names, absolute paths, any path separator and the ``.`` /
76
+ ``..`` special segments, so skill / script names cannot traverse out of the
77
+ ``.mini`` root or address sibling directories. Best-effort: never raises.
78
+ """
79
+ if name is None:
80
+ return None
81
+ name = str(name).strip()
82
+ if not name:
83
+ return None
84
+ if name in (".", ".."):
85
+ return None
86
+ # Any separator at all means "this is trying to be a path" -> refuse.
87
+ # Reject any path separator on either platform (plus a bare backslash), so
88
+ # a name can never contain a directory component -- it cannot traverse.
89
+ # Any separator (on either platform) plus a bare backslash means
90
+ # "this is trying to be a path" -> refuse, so a name can never hold a
91
+ # directory component and cannot traverse.
92
+ if any(sep in name for sep in (os.sep, "\\", "/")):
93
+ return None
94
+ return name
95
+
96
+
97
+ # -- discovery -------------------------------------------------------------
98
+ def _list_skill_names(allowed_dir):
99
+ """Return the sorted names of skills (dirs holding a ``skill.md``)."""
100
+ sd = skills_dir(allowed_dir)
101
+ if not os.path.isdir(sd):
102
+ return []
103
+ out = []
104
+ for entry in sorted(os.listdir(sd)):
105
+ if os.path.isdir(os.path.join(sd, entry)) and \
106
+ os.path.isfile(os.path.join(sd, entry, SKILL_FILENAME)):
107
+ out.append(entry)
108
+ return out
109
+
110
+
111
+ def list_scripts(allowed_dir, skill_name):
112
+ """Return the executable Python script names (without ``.py``) in a skill."""
113
+ safe = _safe_name(skill_name)
114
+ if not safe:
115
+ return []
116
+ base = skill_dir(allowed_dir, safe)
117
+ if not os.path.isdir(base):
118
+ return []
119
+ out = []
120
+ for entry in sorted(os.listdir(base)):
121
+ if entry.endswith(SCRIPT_SUFFIX):
122
+ out.append(entry[: -len(SCRIPT_SUFFIX)])
123
+ return out
124
+
125
+
126
+ def ensure_skills_dir(allowed_dir):
127
+ """Create ``.mini`` and a skeleton ``SKILLS.md`` if they do not exist yet.
128
+
129
+ Best-effort and never raises: a skills hiccup must never break the loop.
130
+ """
131
+ try:
132
+ sd = skills_dir(allowed_dir)
133
+ os.makedirs(sd, exist_ok=True)
134
+ if not os.path.exists(summary_path(allowed_dir)):
135
+ _write_summary(allowed_dir, [])
136
+ except OSError:
137
+ pass
138
+
139
+
140
+ def _skill_metadata(allowed_dir, skill_name):
141
+ """Return ``(title, description)`` for a skill read from its ``skill.md``.
142
+
143
+ ``description`` is the ``Summary:`` line if the skill has one, else the first
144
+ non-heading line of the file. ``title`` is the first ``#`` heading, else the
145
+ skill name. Best-effort: a missing/unreadable file yields (name, "").
146
+ """
147
+ path = skill_md_path(allowed_dir, skill_name)
148
+ if not os.path.isfile(path):
149
+ return skill_name, ""
150
+ try:
151
+ with open(path, "r", encoding="utf-8") as f:
152
+ content = f.read()
153
+ except OSError:
154
+ return skill_name, ""
155
+
156
+ description = ""
157
+ title = skill_name
158
+ for line in content.splitlines():
159
+ stripped = line.strip()
160
+ if not title and stripped.startswith("#"):
161
+ title = stripped.lstrip("#").strip() or skill_name
162
+ continue
163
+ if stripped.lower().startswith("summary:"):
164
+ description = stripped.split(":", 1)[1].strip()
165
+ break
166
+ if not stripped or stripped.startswith("#"):
167
+ continue
168
+ description = stripped
169
+ break
170
+ return (title or skill_name), description
171
+
172
+
173
+ # -- tools -----------------------------------------------------------------
174
+ def list_skills(allowed_dir):
175
+ """List available skills (name + one-line summary). Creates ``.mini`` first.
176
+
177
+ No parameters from the model (``allowed_dir`` is bound at wiring time).
178
+ """
179
+ ensure_skills_dir(allowed_dir)
180
+ skills = _list_skill_names(allowed_dir)
181
+ # Keep the high-level SKILLS.md summary in sync with what is on disk (e.g.
182
+ # scripts dropped into a skill after it was created), so list_skills always
183
+ # reflects the current state.
184
+ if skills:
185
+ _write_summary(allowed_dir, skills)
186
+ if not skills:
187
+ return "No skills yet. Use create_skill to add one."
188
+ lines = ["Available skills:", ""]
189
+ for name in skills:
190
+ _title, desc = _skill_metadata(allowed_dir, name)
191
+ lines.append(f"- {name}: {desc}")
192
+ return "\n".join(lines)
193
+
194
+
195
+ def read_skill(allowed_dir, skill_name):
196
+ """Return the contents of a skill's ``skill.md`` by name.
197
+
198
+ If the skill does not exist, returns an error listing the available skills
199
+ so the agent can recover.
200
+ """
201
+ safe = _safe_name(skill_name)
202
+ if not safe:
203
+ return "Error: invalid skill name."
204
+ path = skill_md_path(allowed_dir, safe)
205
+ if not os.path.isfile(path):
206
+ available = _list_skill_names(allowed_dir)
207
+ listing = ", ".join(available) if available else "none"
208
+ return f"Error: no skill named {skill_name!r}. Available skills: {listing}."
209
+ try:
210
+ with open(path, "r", encoding="utf-8") as f:
211
+ return f.read()
212
+ except OSError as e:
213
+ return f"Error: {e}"
214
+
215
+
216
+ def create_skill(allowed_dir, skill_name, description, title=None, overwrite=False):
217
+ """Create a new skill: its sub-directory and ``skill.md``.
218
+
219
+ Refreshes the high-level ``SKILLS.md`` summary afterwards so the new skill
220
+ shows up in list_skills. Refuses to clobber an existing skill unless
221
+ ``overwrite`` is set. Best-effort: never raises, returns an Error string.
222
+ """
223
+ safe = _safe_name(skill_name)
224
+ if not safe:
225
+ return "Error: invalid skill name. Use a short, single-segment name."
226
+ if not description:
227
+ return "Error: a description is required."
228
+
229
+ sdir = skill_dir(allowed_dir, safe)
230
+ mpath = skill_md_path(allowed_dir, safe)
231
+ if os.path.exists(mpath) and not overwrite:
232
+ return (f"Error: skill {safe!r} already exists (use overwrite to replace "
233
+ f"it).")
234
+
235
+ try:
236
+ os.makedirs(sdir, exist_ok=True)
237
+ with open(mpath, "w", encoding="utf-8") as f:
238
+ f.write(_skill_md_template(title or safe, description))
239
+ _write_summary(allowed_dir, _list_skill_names(allowed_dir))
240
+ return f"Created skill {safe!r} at {mpath}"
241
+ except OSError as e:
242
+ return f"Error: {e}"
243
+
244
+
245
+ def delete_skill(allowed_dir, skill_name):
246
+ """Delete a skill's sub-directory (and drop it from SKILLS.md).
247
+
248
+ Kept minimal and only surfaced if ever wanted as a tool; best-effort.
249
+ """
250
+ safe = _safe_name(skill_name)
251
+ if not safe:
252
+ return "Error: invalid skill name."
253
+ sdir = skill_dir(allowed_dir, safe)
254
+ if not os.path.isdir(sdir):
255
+ return f"Error: no skill named {skill_name!r}."
256
+ try:
257
+ shutil.rmtree(sdir)
258
+ _write_summary(allowed_dir, _list_skill_names(allowed_dir))
259
+ return f"Deleted skill {safe!r}."
260
+ except OSError as e:
261
+ return f"Error: {e}"
262
+
263
+
264
+ def run_skill(allowed_dir, skill_name, script_name, args=None):
265
+ """Execute one of a skill's Python scripts and return its output.
266
+
267
+ ``args`` is an optional string of command-line arguments, split with
268
+ :func:`shlex.split` and passed as ``argv`` (never through a shell, so there
269
+ is no shell-injection surface). The script is run with the same interpreter
270
+ that runs mini-code (``sys.executable``). The script path is resolved under
271
+ the skill's directory and checked to stay inside it.
272
+ """
273
+ safe = _safe_name(skill_name)
274
+ script = _safe_name(script_name)
275
+ if not safe or not script:
276
+ return "Error: invalid skill or script name."
277
+ if not script.endswith(SCRIPT_SUFFIX):
278
+ script += SCRIPT_SUFFIX
279
+
280
+ base = os.path.realpath(skill_dir(allowed_dir, safe))
281
+ script_path = os.path.realpath(os.path.join(base, script))
282
+ # Containment: the script must live directly inside the skill dir.
283
+ if os.path.dirname(script_path) != base:
284
+ return "Error: script path escapes the skill directory."
285
+ if not os.path.isfile(script_path):
286
+ available = ", ".join(list_scripts(allowed_dir, safe)) or "none"
287
+ return (f"Error: no script {script!r} in skill {safe!r}. "
288
+ f"Available scripts: {available}.")
289
+
290
+ argv = shlex.split(args) if args else []
291
+ cmd = [sys.executable, script_path] + argv
292
+ try:
293
+ result = subprocess.run(cmd, capture_output=True, text=True,
294
+ timeout=DEFAULT_TOOL_TIMEOUT, check=False)
295
+ except Exception as e:
296
+ return f"Error: {e}"
297
+ out = result.stdout
298
+ if result.stderr:
299
+ out += "\nSTDERR:\n" + result.stderr
300
+ return out
301
+
302
+
303
+ # -- summary ---------------------------------------------------------------
304
+ def _skill_md_template(title, description):
305
+ return (
306
+ f"# {title}\n\n"
307
+ f"Summary: {description}\n\n"
308
+ "## How to use\n"
309
+ f"{description}\n\n"
310
+ "## Scripts\n"
311
+ "- (none yet)\n"
312
+ )
313
+
314
+
315
+ def _write_summary(allowed_dir, skill_names):
316
+ """Write the high-level ``SKILLS.md`` summary from the given skill names."""
317
+ lines = [
318
+ "# Skills",
319
+ "",
320
+ "This is the high-level summary of the skills kept in the local "
321
+ "`.mini` directory. Each skill lives in its own sub-directory with a "
322
+ "`skill.md` (what it is / how to use it) and optional executable "
323
+ "Python scripts. Use `list_skills` / `read_skill` to consult them, "
324
+ "`create_skill` to add one, and `run_skill` to run a skill's scripts.",
325
+ "",
326
+ ]
327
+ if not skill_names:
328
+ lines += ["No skills yet.", ""]
329
+ for name in skill_names:
330
+ _title, desc = _skill_metadata(allowed_dir, name)
331
+ lines.append(f"## {name}")
332
+ lines.append(desc or "(no summary)")
333
+ scripts = list_scripts(allowed_dir, name)
334
+ if scripts:
335
+ lines.append("")
336
+ lines.append(f"Scripts: {', '.join(scripts)}")
337
+ lines.append("")
338
+ try:
339
+ with open(summary_path(allowed_dir), "w", encoding="utf-8") as f:
340
+ f.write("\n".join(lines))
341
+ except OSError:
342
+ pass
343
+
344
+
345
+ # -- wiring (tool schemas + bound functions) -------------------------------
346
+ def _skill_functions(allowed_dir):
347
+ """{name: functools.partial} for the four skill tools, allowed_dir bound."""
348
+ return {
349
+ "list_skills": functools.partial(list_skills, allowed_dir),
350
+ "read_skill": functools.partial(read_skill, allowed_dir),
351
+ "create_skill": functools.partial(create_skill, allowed_dir),
352
+ "run_skill": functools.partial(run_skill, allowed_dir),
353
+ }
354
+
355
+
356
+ def get_skills_tools(allowed_dir):
357
+ """Return the OpenAI tool schemas for the four skill tools."""
358
+ from . import tools
359
+ return tools.util_get_tools(functions=_skill_functions(allowed_dir))
360
+
361
+
362
+ def get_skills_tools_dict(allowed_dir):
363
+ """Return {name: bound-function} for the four skill tools."""
364
+ from . import tools
365
+ return tools.util_get_tools_dict(functions=_skill_functions(allowed_dir))
@@ -1,8 +1,11 @@
1
- """mini_code_cli.subagents.builtin -- two worked example sub-agents.
1
+ """mini_code_cli.subagents.builtin -- the built-in sub-agents.
2
2
 
3
3
  These are registered on import (see :mod:`mini_code_cli.subagents`). They double
4
4
  as documentation for how to build your own: a sub-agent is just a small class
5
- configuring a name, a description, and a few knobs.
5
+ configuring a name, a description, a set of tools, and a few knobs. Each one
6
+ targets a specific kind of work -- its ``system_prompt`` overrides add a short,
7
+ task-specific preamble on top of the shared base prompt so the nested agent
8
+ knows exactly what "done" looks like for this kind of task.
6
9
  """
7
10
  from .base import SubAgent
8
11
  from . import register
@@ -74,6 +77,95 @@ class PlanSubAgent(SubAgent):
74
77
  )
75
78
 
76
79
 
77
- # Register the built-in examples.
80
+ class SoftwareEngineeringSubAgent(SubAgent):
81
+ """Implement, edit, refactor or test code in a project.
82
+
83
+ This is the "do the actual engineering" sub-agent. It can read and write
84
+ files and run shell commands, so it is meant for real coding work: writing a
85
+ new module, refactoring, wiring up a fix, running the tests / a build to
86
+ check it. It is given a task-specific system prompt that biases it toward
87
+ producing working, tested, minimal changes rather than exploratory writing.
88
+
89
+ Because it can run shell commands it should only be used in sessions where
90
+ the caller is comfortable with that; the parent's sandbox / shell-safety
91
+ rules still apply.
92
+ """
93
+
94
+ name = "software_engineer"
95
+ description = (
96
+ "Software-engineering sub-agent: write, edit, refactor, fix or test code "
97
+ "in the project. It can read/write files and run shell commands (tests, "
98
+ "builds, etc.) to verify the work. Hands back a summary of the changes."
99
+ )
100
+ tools = ["read_file", "write_file", "grep", "glob", "run_shell_command"]
101
+ enable_shell = True
102
+ max_steps = 20
103
+
104
+ def system_prompt(self, task, parent):
105
+ base = super().system_prompt(task, parent)
106
+ return base + (
107
+ "\n\n---\nYou are the SOFTWARE ENGINEERING sub-agent: you write and "
108
+ "modify code to get a real task done. Follow these guidelines:\n"
109
+ "* Understand the relevant code before editing: read the files and "
110
+ "use grep/glob to find what you need. Prefer small, targeted edits over "
111
+ "rewriting large files.\n"
112
+ "* Keep changes minimal and focused on the task; do not touch unrelated "
113
+ "code.\n"
114
+ "* Make edits that are syntactically valid and consistent with the "
115
+ "existing style (imports, formatting, naming).\n"
116
+ "* When it makes sense, verify your work: run the project's tests, a "
117
+ "build, a linter or the code itself with run_shell_command, and use "
118
+ "the output to fix problems. Do not claim something works without "
119
+ "checking when you are able to check.\n"
120
+ "* If a task is ambiguous, make a sensible, minimal choice and state "
121
+ "it. Note any trade-offs or follow-ups.\n"
122
+ "When finished, write a concise summary of what you changed, why, and "
123
+ "how you verified it (including any test/build commands you ran and "
124
+ "their results) to report.md."
125
+ )
126
+
127
+
128
+ class GenericSubAgent(SubAgent):
129
+ """A general-purpose helper for tasks that do not fit a narrower agent.
130
+
131
+ This is the catch-all sub-agent: it has the full set of read/write tools and
132
+ may run shell commands, so it can handle "everything else" -- formatting a
133
+ document, organising files, drafting text, running a quick script, or any
134
+ one-off task the main agent wants to farm out. Its system prompt is a light,
135
+ task-agnostic scaffold that works for a broad range of requests.
136
+ """
137
+
138
+ name = "generic"
139
+ description = (
140
+ "General-purpose sub-agent for tasks that do not fit a more specialised "
141
+ "one. It can read/write files and run shell commands, so it suits most "
142
+ "one-off jobs. Hands back a summary of what it did."
143
+ )
144
+ tools = ["read_file", "write_file", "grep", "glob", "run_shell_command"]
145
+ enable_shell = True
146
+ max_steps = 12
147
+
148
+ def system_prompt(self, task, parent):
149
+ base = super().system_prompt(task, parent)
150
+ return base + (
151
+ "\n\n---\nYou are a GENERAL-PURPOSE sub-agent: a capable, careful "
152
+ "helper handed a single task. Follow these guidelines:\n"
153
+ "* Start by making sure you understand the task. If anything is "
154
+ "unclear, make a sensible, clearly-stated assumption and proceed -- "
155
+ "do not stall.\n"
156
+ "* Use the tools available to you: read before you write, and prefer "
157
+ "small, correct steps over large, risky ones.\n"
158
+ "* Prefer doing the task for real (editing files, running commands) "
159
+ "over merely describing how it could be done.\n"
160
+ "* If you run commands or make edits that could fail, check the "
161
+ "result and adapt.\n"
162
+ "When finished, write a concise summary of what you did and any "
163
+ "decisions or follow-ups to report.md."
164
+ )
165
+
166
+
167
+ # Register the built-in sub-agents.
78
168
  register(ResearchSubAgent())
79
169
  register(PlanSubAgent())
170
+ register(SoftwareEngineeringSubAgent())
171
+ register(GenericSubAgent())
mini_code_cli/tools.py CHANGED
@@ -1,8 +1,18 @@
1
+ """mini_code_cli.tools -- the tool functions the agent loop can expose to the LLM.
2
+
3
+ The plain functions in this module (``read_file``, ``grep``, ...) are the
4
+ default tools. They can be discovered automatically (see
5
+ :func:`util_get_tools` / :func:`util_get_tools_dict`). A caller may also pass an
6
+ explicit ``functions`` mapping so another feature (e.g. :mod:`mini_code_cli.skills`,
7
+ whose tools already have ``allowed_dir`` bound) can reuse the schema/formatting.
8
+ """
1
9
  import glob as glob_module
2
10
  import subprocess
3
11
  import urllib.parse
4
12
  import urllib.request
5
13
  import json
14
+ import inspect
15
+ import functools
6
16
 
7
17
  # Default timeout (seconds) for a single tool invocation. Mirrors the CLI's
8
18
  # --tool-timeout default so a tool that is slow/hangs gets aborted.
@@ -144,43 +154,96 @@ def run_shell_command(command, timeout=DEFAULT_TOOL_TIMEOUT, *args, **kwargs):
144
154
  return f"Error: {e}"
145
155
 
146
156
 
147
- def util_get_tools(allowed_tools=None, forbidden_tools=None):
148
- """Return a JSON string describing all tool functions in this file for LLM usage."""
149
- import inspect
157
+ def _local_function_names():
158
+ """Names of the real tool functions defined in this module (not util_/_)."""
159
+ return [name for name, obj in globals().items()
160
+ if callable(obj) and obj.__module__ == __name__
161
+ and not name.startswith('util_') and not name.startswith("_")]
162
+
163
+
164
+ def _param_type(annotation):
165
+ """Map a type annotation to an OpenAI-schema type string."""
166
+ param_type = str(annotation) if annotation != inspect.Parameter.empty else "string"
167
+ if 'int' in param_type.lower() or 'integer' in param_type.lower():
168
+ return "integer"
169
+ if 'float' in param_type.lower() or 'number' in param_type.lower():
170
+ return "number"
171
+ if 'bool' in param_type.lower():
172
+ return "boolean"
173
+ if 'list' in param_type.lower() or 'array' in param_type.lower():
174
+ return "array"
175
+ return "string"
176
+
177
+
178
+ def _schema_params(func):
179
+ """Return ``(properties, required)`` for a tool callable's signature.
180
+
181
+ Handles both plain module functions and :func:`functools.partial` objects
182
+ (used by skills, which bind ``allowed_dir`` up front): parameters that are
183
+ already bound on a partial are omitted from the schema, and remaining
184
+ non-default parameters are marked required. ``*args`` / ``**kwargs``
185
+ catch-alls are always skipped.
186
+ """
187
+ if isinstance(func, functools.partial):
188
+ underlying = func.func
189
+ bound_kwargs = dict(func.keywords or {})
190
+ bound_positional = list(func.args)
191
+ else:
192
+ underlying = func
193
+ bound_kwargs = {}
194
+ bound_positional = []
195
+
196
+ sig = inspect.signature(underlying)
197
+ # Bind leading positional args (in signature order) to the partial's
198
+ # positional args, so a positional bound arg hides the right parameter.
199
+ positional_names = [
200
+ pn for pn, p in sig.parameters.items()
201
+ if p.kind not in (inspect.Parameter.VAR_POSITIONAL,
202
+ inspect.Parameter.VAR_KEYWORD)
203
+ ]
204
+ positional_bound = {}
205
+ for i, pname in enumerate(positional_names):
206
+ if i < len(bound_positional):
207
+ positional_bound[pname] = bound_positional[i]
208
+
209
+ properties = {}
210
+ required = []
211
+ for param_name, param in sig.parameters.items():
212
+ if param.kind in (inspect.Parameter.VAR_POSITIONAL,
213
+ inspect.Parameter.VAR_KEYWORD):
214
+ continue
215
+ if param_name in bound_kwargs or param_name in positional_bound:
216
+ # Already bound on the partial -- the LLM must not supply it.
217
+ continue
218
+ param_type = _param_type(param.annotation)
219
+ properties[param_name] = {"type": param_type}
220
+ if param.default == inspect.Parameter.empty:
221
+ required.append(param_name)
222
+ return properties, required
223
+
224
+
225
+ def util_get_tools(allowed_tools=None, forbidden_tools=None, functions=None):
226
+ """Return a JSON string describing tool functions for LLM usage.
227
+
228
+ By default scans this module's own tool functions. When ``functions`` is a
229
+ {name: callable} mapping, those are described instead (in ``functions``
230
+ order) -- letting other features (e.g. skills, with ``allowed_dir`` already
231
+ bound) contribute their own tools while reusing this formatting.
232
+ """
150
233
  tools = []
151
- func_names = [name for name, obj in globals().items() if callable(obj) and obj.__module__ == __name__ and not name.startswith('util_') and not name.startswith("_")]
152
-
234
+
235
+ if functions:
236
+ items = list(functions.items())
237
+ else:
238
+ items = [(name, globals()[name]) for name in _local_function_names()]
239
+
153
240
  if allowed_tools:
154
- func_names = [name for name in func_names if name in allowed_tools]
241
+ items = [(n, f) for n, f in items if n in allowed_tools]
155
242
  if forbidden_tools:
156
- func_names = [name for name in func_names if name not in forbidden_tools]
157
-
158
- for name in func_names:
159
- func = globals()[name]
160
- sig = inspect.signature(func)
161
- parameters = {}
162
- required = []
163
- for param_name, param in sig.parameters.items():
164
- # Skip *args and **kwargs: they are catch-alls, not real inputs the
165
- # LLM should (or can) supply, so they must not appear in the schema.
166
- if param.kind in (inspect.Parameter.VAR_POSITIONAL,
167
- inspect.Parameter.VAR_KEYWORD):
168
- continue
169
- param_type = str(param.annotation) if param.annotation != inspect.Parameter.empty else "string"
170
- # Simplify common types
171
- if 'int' in param_type.lower() or 'integer' in param_type.lower():
172
- param_type = "integer"
173
- elif 'float' in param_type.lower() or 'number' in param_type.lower():
174
- param_type = "number"
175
- elif 'bool' in param_type.lower():
176
- param_type = "boolean"
177
- elif 'list' in param_type.lower() or 'array' in param_type.lower():
178
- param_type = "array"
179
- else:
180
- param_type = "string"
181
- parameters[param_name] = {"type": param_type}
182
- if param.default == inspect.Parameter.empty:
183
- required.append(param_name)
243
+ items = [(n, f) for n, f in items if n not in forbidden_tools]
244
+
245
+ for name, func in items:
246
+ properties, required = _schema_params(func)
184
247
  tool = {
185
248
  "type": "function",
186
249
  "function": {
@@ -188,7 +251,7 @@ def util_get_tools(allowed_tools=None, forbidden_tools=None):
188
251
  "description": func.__doc__ if func.__doc__ else f"Function {name}",
189
252
  "parameters": {
190
253
  "type": "object",
191
- "properties": parameters,
254
+ "properties": properties,
192
255
  "required": required
193
256
  }
194
257
  }
@@ -197,25 +260,33 @@ def util_get_tools(allowed_tools=None, forbidden_tools=None):
197
260
  return tools
198
261
 
199
262
 
200
- def util_get_tools_dict(allowed_tools=None, forbidden_tools=None):
201
- """Return a dictionary mapping tool names to their function objects."""
263
+ def util_get_tools_dict(allowed_tools=None, forbidden_tools=None, functions=None):
264
+ """Return a dictionary mapping tool names to their function objects.
265
+
266
+ ``functions`` is an optional {name: callable} mapping used in place of this
267
+ module's own functions (see :func:`util_get_tools`)."""
202
268
  result = {}
203
- func_names = [name for name, obj in globals().items() if callable(obj) and obj.__module__ == __name__ and not name.startswith('util_') and not name.startswith("_")]
269
+
270
+ if functions:
271
+ items = list(functions.items())
272
+ else:
273
+ items = [(name, globals()[name]) for name in _local_function_names()]
274
+
204
275
  if allowed_tools:
205
- func_names = [name for name in func_names if name in allowed_tools]
276
+ items = [(n, f) for n, f in items if n in allowed_tools]
206
277
  if forbidden_tools:
207
- func_names = [name for name in func_names if name not in forbidden_tools]
278
+ items = [(n, f) for n, f in items if n not in forbidden_tools]
208
279
 
209
- for name in func_names:
210
- func = globals()[name]
280
+ for name, func in items:
211
281
  desc = func.__doc__ if func.__doc__ else f"Function {name}"
212
282
  result[name] = {"function": func, "description": desc}
213
283
  return result
214
284
 
285
+
215
286
  shell_tools = [
216
287
  "run_shell_command"
217
288
  ]
218
289
 
219
- if __name__=="__main__":
290
+ if __name__ == "__main__":
220
291
  print(util_get_tools())
221
- # print(json.dumps(tools_dict, indent=4))
292
+ # print(json.dumps(tools_dict, indent=4))
mini_code_cli/ui.py CHANGED
@@ -99,6 +99,12 @@ class Console:
99
99
  # "… thinking" spinner state (see start_thinking / stop_thinking).
100
100
  self._thinking_thread = None
101
101
  self._thinking_stop = None
102
+ # "… calling tool" spinner state (see start_tool_call / stop_tool_call).
103
+ # Mirrors the thinking spinner: a daemon thread that rewrites a single
104
+ # live line in place (via the transient region) so the tool-execution
105
+ # phase looks alive without scrolling the transcript.
106
+ self._tool_call_thread = None
107
+ self._tool_call_stop = None
102
108
  # Live "transient" region (non-verbose mode) renders the tool-call area
103
109
  # and the usage line without scrolling the screen. We keep a single
104
110
  # invariant: whenever ``_transient_lines`` is > 0 a block of that many
@@ -432,6 +438,55 @@ class Console:
432
438
  if thread is not None and thread.is_alive():
433
439
  thread.join(timeout=1.0)
434
440
 
441
+ def start_tool_call(self, message="Calling tool"):
442
+ """Start a background "… calling tool" spinner (non-scrolling).
443
+
444
+ Shown while the agent executes a tool call, mirroring
445
+ :meth:`start_thinking` which covers the LLM request. Runs a short
446
+ animation in a daemon thread that rewrites a single live line in place
447
+ via the transient region, so the wait looks alive without scrolling the
448
+ transcript. On a real colour/utf-8 TTY it animates in place; on piped /
449
+ ASCII output it simply prints one static line and starts no thread.
450
+ Stop it with :meth:`stop_tool_call` before drawing the next turn.
451
+ """
452
+ self.stop_tool_call() # be defensive: never leak more than one spinner
453
+ if not self._can_clear_cursor():
454
+ self.emit(self.style(f"\u2026 {message}", fg="magenta", dim=True))
455
+ return
456
+ self._tool_call_stop = threading.Event()
457
+ self._tool_call_thread = threading.Thread(
458
+ target=self._spin_tool_call, args=(message,), daemon=True)
459
+ self._tool_call_thread.start()
460
+
461
+ def _spin_tool_call(self, message):
462
+ """Animation loop for the tool-call spinner (mirror of :meth:`_spin`)."""
463
+ stop = self._tool_call_stop
464
+ i = 0
465
+ while not stop.is_set():
466
+ dots = "\u2026 " * min((i % 3) + 1, 3)
467
+ i += 1
468
+ self.emit_transient(self.style(f"{dots}{message}", fg="magenta", dim=True))
469
+ stop.wait(0.3)
470
+ # Replace the spinner line with a blank so the following real output
471
+ # lands cleanly; the blank stays tracked as the transient block and is
472
+ # cleared by the next :meth:`emit` (e.g. the next assistant turn).
473
+ self.emit_transient(None)
474
+
475
+ def stop_tool_call(self):
476
+ """Stop the "… calling tool" spinner started by :meth:`start_tool_call`.
477
+
478
+ Signals the animation thread to finish its current frame and exit, then
479
+ joins it, so no writes race with the main thread's subsequent output.
480
+ Safe to call when no spinner is running (a no-op)."""
481
+ stop = self._tool_call_stop
482
+ thread = self._tool_call_thread
483
+ self._tool_call_stop = None
484
+ self._tool_call_thread = None
485
+ if stop is not None:
486
+ stop.set()
487
+ if thread is not None and thread.is_alive():
488
+ thread.join(timeout=1.0)
489
+
435
490
  def tool_group(self, name, params=None, result=None, status="ran"):
436
491
  """Render one attempted tool call: its name, parameters and result."""
437
492
  self.emit(self.style(f"{self.glyph('gear')} {name}", fg="magenta", bold=True))
@@ -1,7 +1,7 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mini-code-cli
3
- Version: 0.0.8.4
4
- Summary: Mini Coding CLI.
3
+ Version: 0.1.0.1
4
+ Summary: Mini Coding Agent. Allows you to easily experiment with a small coding agent.
5
5
  Author-email: Nikolai Rozanov <nikolai.rozanov@gmail.com>
6
6
  License: GNU AFFERO GENERAL PUBLIC LICENSE
7
7
  Version 3, 19 November 2007
@@ -700,6 +700,9 @@ mini-code --url "https://api.deepseek.com" --model "deepseek-v4-flash"
700
700
  ```bash
701
701
  mini-code --enable-shell --auto-mode
702
702
  mini-code -s -a --sudo
703
+ mini-code -s -a -p -g -l --sudo #plan, previous session, subagents (#or short-hand the next commands)
704
+ mini-code --all
705
+ mini-code --all-last
703
706
  ```
704
707
 
705
708
  4. Advanced usage:
@@ -868,4 +871,19 @@ vllm serve ornith-ai/Ornith-1.5-397B-FP8 \
868
871
  --trust-remote-code
869
872
  ```
870
873
 
874
+ ## Citation
875
+
876
+ If you use `mini-code-cli` in your research or writing, please cite it using the BibTeX entry below:
877
+
878
+ ```bibtex
879
+ @misc{mini-code-cli,
880
+ author = {Rozanov, Nikolai},
881
+ title = {mini-code-cli: A minimal, dependency-light coding agent},
882
+ year = {2026},
883
+ version = {0.1.0.0},
884
+ howpublished = {\url{https://github.com/ai-nikolai/mini-code-cli}},
885
+ note = {A no-bloat, dependency-free agent CLI for experimenting.}
886
+ }
887
+ ```
888
+
871
889
  ## (C) Nikolai Rozanov, 2026 - Present
@@ -1,22 +1,23 @@
1
1
  mini_code_cli/__init__.py,sha256=iNgHmKSHOuH99obGjnUMoS2JP4Gw4AOrIfu68EYaZQM,77
2
- mini_code_cli/agent_loop.py,sha256=RMr3VOyUc_zPEOW629MDVoj9dZ1mGDLywWF4Bcmq9j8,52671
2
+ mini_code_cli/agent_loop.py,sha256=jQEzWknHid0rKA8-vzES1RONg4tVJ8l4ImBleBC_RDE,58124
3
3
  mini_code_cli/config.py,sha256=AvzDZAKynQbuj5lcTtL-DcUwhyLa99QmNrLK6fYpdOA,814
4
4
  mini_code_cli/permissions.py,sha256=uQpQlUtQyVICpjUqdh9QQ_mgl51hZrGLHJzQR9vG124,2017
5
5
  mini_code_cli/persistence.py,sha256=L47NhXs6R54KETd8R17oGGxCXNEf7bjmvlwAJqtspLI,9944
6
- mini_code_cli/plan.py,sha256=uvbs8BcXKFO8Melhjt2R3ZA8JZJ2n4ELsMI6UKeZVyo,4819
7
- mini_code_cli/prompts.py,sha256=JRrRWwHc3PGCq1efAloQ_mXzGuYCSUJKUAJz9pp3UxQ,3565
6
+ mini_code_cli/plan.py,sha256=M5WZQuiN8F_K7zyWIrU_5R9L8dcN9k8iYouXzfL_KOs,6985
7
+ mini_code_cli/prompts.py,sha256=X05Krwf9X46WinbHzHj8WYq4RTnO56KZpMiRYS2CGLw,5452
8
8
  mini_code_cli/runner.py,sha256=oYUAzbQOrkakuR2uT1OnIrruh_XJYWEDzKpM09T4Zp0,1927
9
9
  mini_code_cli/sandbox.py,sha256=t7xS_AIpE8fbo5kj9zbUFbfEaAnq6iUE7plQcn6-uAk,2128
10
10
  mini_code_cli/shell_safety_utils.py,sha256=5E126vGnG7iIuTZe_R5JrxB_zxZpMKGe_pilz_Z2r4w,5551
11
- mini_code_cli/tools.py,sha256=66hQUyDC_3HCC6l4ua_f1YwFL0FcJ98G5X8wx7h-ojQ,8660
12
- mini_code_cli/ui.py,sha256=AUwn0OFt3yFx397-V8kYuidseCBxFHgO1KWDKrlLCOU,29371
11
+ mini_code_cli/skills.py,sha256=baaz0RkPQ-OgWWwH5sHsKBmJdR430yT4HK3VLX0_88Y,13704
12
+ mini_code_cli/tools.py,sha256=bW1zYyIj6OFp5TIcg5DCwheN-4xI1LQmDOQaHIfRV7M,10998
13
+ mini_code_cli/ui.py,sha256=sNfjwxLWtRF3C3VpSBP9L0uncVgFhZmGquw8u9O956U,32127
13
14
  mini_code_cli/utils.py,sha256=ffUAIisO8_h7VvlwAOLhdoNSAIdssGdtQHXm-i8bcLE,4620
14
15
  mini_code_cli/subagents/__init__.py,sha256=3fgcwY0pNq5iW29OxLPtSA3H5IKXhl2LUhwbBjLUDuU,1884
15
16
  mini_code_cli/subagents/base.py,sha256=bhZEENoTVjebfqaEKS7uPWeU0SwQWFN3BD40EtziPbg,12660
16
- mini_code_cli/subagents/builtin.py,sha256=bUCXV_FzymjegKrYF2me2GPY7dBs8PbomVSkHIaWVSQ,2891
17
- mini_code_cli-0.0.8.4.dist-info/licenses/LICENSE,sha256=hIahDEOTzuHCU5J2nd07LWwkLW7Hko4UFO__ffsvB-8,34523
18
- mini_code_cli-0.0.8.4.dist-info/METADATA,sha256=OXsCt9J7CvmBnkH6gqybMemXnEtydd0gWvrGffytG-Q,52092
19
- mini_code_cli-0.0.8.4.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
20
- mini_code_cli-0.0.8.4.dist-info/entry_points.txt,sha256=MwDp5UbXpI7wodSmOSYL8_nKutnBiyp96ZDDtaX7x14,60
21
- mini_code_cli-0.0.8.4.dist-info/top_level.txt,sha256=3Hb6PpNkJYXvzDKXuuFJRvzCdsTRh0TuRWz-ScJ6qV0,14
22
- mini_code_cli-0.0.8.4.dist-info/RECORD,,
17
+ mini_code_cli/subagents/builtin.py,sha256=ENzuooXIylzfZZ8uYkZmdxgCrCQ4pdbfrOONkBdGSPg,7598
18
+ mini_code_cli-0.1.0.1.dist-info/licenses/LICENSE,sha256=hIahDEOTzuHCU5J2nd07LWwkLW7Hko4UFO__ffsvB-8,34523
19
+ mini_code_cli-0.1.0.1.dist-info/METADATA,sha256=Er_McRLgRQ41CpufVZSHZeN1ZjBTGpf_s-odKMijoCE,52757
20
+ mini_code_cli-0.1.0.1.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
21
+ mini_code_cli-0.1.0.1.dist-info/entry_points.txt,sha256=MwDp5UbXpI7wodSmOSYL8_nKutnBiyp96ZDDtaX7x14,60
22
+ mini_code_cli-0.1.0.1.dist-info/top_level.txt,sha256=3Hb6PpNkJYXvzDKXuuFJRvzCdsTRh0TuRWz-ScJ6qV0,14
23
+ mini_code_cli-0.1.0.1.dist-info/RECORD,,