npcsh 0.1.2__py3-none-any.whl → 1.1.13__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.
Files changed (143) hide show
  1. npcsh/_state.py +3508 -0
  2. npcsh/alicanto.py +65 -0
  3. npcsh/build.py +291 -0
  4. npcsh/completion.py +206 -0
  5. npcsh/config.py +163 -0
  6. npcsh/corca.py +50 -0
  7. npcsh/execution.py +185 -0
  8. npcsh/guac.py +46 -0
  9. npcsh/mcp_helpers.py +357 -0
  10. npcsh/mcp_server.py +299 -0
  11. npcsh/npc.py +323 -0
  12. npcsh/npc_team/alicanto.npc +2 -0
  13. npcsh/npc_team/alicanto.png +0 -0
  14. npcsh/npc_team/corca.npc +12 -0
  15. npcsh/npc_team/corca.png +0 -0
  16. npcsh/npc_team/corca_example.png +0 -0
  17. npcsh/npc_team/foreman.npc +7 -0
  18. npcsh/npc_team/frederic.npc +6 -0
  19. npcsh/npc_team/frederic4.png +0 -0
  20. npcsh/npc_team/guac.png +0 -0
  21. npcsh/npc_team/jinxs/code/python.jinx +11 -0
  22. npcsh/npc_team/jinxs/code/sh.jinx +34 -0
  23. npcsh/npc_team/jinxs/code/sql.jinx +16 -0
  24. npcsh/npc_team/jinxs/modes/alicanto.jinx +194 -0
  25. npcsh/npc_team/jinxs/modes/corca.jinx +249 -0
  26. npcsh/npc_team/jinxs/modes/guac.jinx +317 -0
  27. npcsh/npc_team/jinxs/modes/plonk.jinx +214 -0
  28. npcsh/npc_team/jinxs/modes/pti.jinx +170 -0
  29. npcsh/npc_team/jinxs/modes/spool.jinx +161 -0
  30. npcsh/npc_team/jinxs/modes/wander.jinx +186 -0
  31. npcsh/npc_team/jinxs/modes/yap.jinx +262 -0
  32. npcsh/npc_team/jinxs/npc_studio/npc-studio.jinx +77 -0
  33. npcsh/npc_team/jinxs/utils/agent.jinx +17 -0
  34. npcsh/npc_team/jinxs/utils/chat.jinx +44 -0
  35. npcsh/npc_team/jinxs/utils/cmd.jinx +44 -0
  36. npcsh/npc_team/jinxs/utils/compress.jinx +140 -0
  37. npcsh/npc_team/jinxs/utils/core/build.jinx +65 -0
  38. npcsh/npc_team/jinxs/utils/core/compile.jinx +50 -0
  39. npcsh/npc_team/jinxs/utils/core/help.jinx +52 -0
  40. npcsh/npc_team/jinxs/utils/core/init.jinx +41 -0
  41. npcsh/npc_team/jinxs/utils/core/jinxs.jinx +32 -0
  42. npcsh/npc_team/jinxs/utils/core/set.jinx +40 -0
  43. npcsh/npc_team/jinxs/utils/edit_file.jinx +94 -0
  44. npcsh/npc_team/jinxs/utils/load_file.jinx +35 -0
  45. npcsh/npc_team/jinxs/utils/ots.jinx +61 -0
  46. npcsh/npc_team/jinxs/utils/roll.jinx +68 -0
  47. npcsh/npc_team/jinxs/utils/sample.jinx +56 -0
  48. npcsh/npc_team/jinxs/utils/search.jinx +130 -0
  49. npcsh/npc_team/jinxs/utils/serve.jinx +26 -0
  50. npcsh/npc_team/jinxs/utils/sleep.jinx +116 -0
  51. npcsh/npc_team/jinxs/utils/trigger.jinx +61 -0
  52. npcsh/npc_team/jinxs/utils/usage.jinx +33 -0
  53. npcsh/npc_team/jinxs/utils/vixynt.jinx +144 -0
  54. npcsh/npc_team/kadiefa.npc +3 -0
  55. npcsh/npc_team/kadiefa.png +0 -0
  56. npcsh/npc_team/npcsh.ctx +18 -0
  57. npcsh/npc_team/npcsh_sibiji.png +0 -0
  58. npcsh/npc_team/plonk.npc +2 -0
  59. npcsh/npc_team/plonk.png +0 -0
  60. npcsh/npc_team/plonkjr.npc +2 -0
  61. npcsh/npc_team/plonkjr.png +0 -0
  62. npcsh/npc_team/sibiji.npc +3 -0
  63. npcsh/npc_team/sibiji.png +0 -0
  64. npcsh/npc_team/spool.png +0 -0
  65. npcsh/npc_team/yap.png +0 -0
  66. npcsh/npcsh.py +296 -112
  67. npcsh/parsing.py +118 -0
  68. npcsh/plonk.py +54 -0
  69. npcsh/pti.py +54 -0
  70. npcsh/routes.py +139 -0
  71. npcsh/spool.py +48 -0
  72. npcsh/ui.py +199 -0
  73. npcsh/wander.py +62 -0
  74. npcsh/yap.py +50 -0
  75. npcsh-1.1.13.data/data/npcsh/npc_team/agent.jinx +17 -0
  76. npcsh-1.1.13.data/data/npcsh/npc_team/alicanto.jinx +194 -0
  77. npcsh-1.1.13.data/data/npcsh/npc_team/alicanto.npc +2 -0
  78. npcsh-1.1.13.data/data/npcsh/npc_team/alicanto.png +0 -0
  79. npcsh-1.1.13.data/data/npcsh/npc_team/build.jinx +65 -0
  80. npcsh-1.1.13.data/data/npcsh/npc_team/chat.jinx +44 -0
  81. npcsh-1.1.13.data/data/npcsh/npc_team/cmd.jinx +44 -0
  82. npcsh-1.1.13.data/data/npcsh/npc_team/compile.jinx +50 -0
  83. npcsh-1.1.13.data/data/npcsh/npc_team/compress.jinx +140 -0
  84. npcsh-1.1.13.data/data/npcsh/npc_team/corca.jinx +249 -0
  85. npcsh-1.1.13.data/data/npcsh/npc_team/corca.npc +12 -0
  86. npcsh-1.1.13.data/data/npcsh/npc_team/corca.png +0 -0
  87. npcsh-1.1.13.data/data/npcsh/npc_team/corca_example.png +0 -0
  88. npcsh-1.1.13.data/data/npcsh/npc_team/edit_file.jinx +94 -0
  89. npcsh-1.1.13.data/data/npcsh/npc_team/foreman.npc +7 -0
  90. npcsh-1.1.13.data/data/npcsh/npc_team/frederic.npc +6 -0
  91. npcsh-1.1.13.data/data/npcsh/npc_team/frederic4.png +0 -0
  92. npcsh-1.1.13.data/data/npcsh/npc_team/guac.jinx +317 -0
  93. npcsh-1.1.13.data/data/npcsh/npc_team/guac.png +0 -0
  94. npcsh-1.1.13.data/data/npcsh/npc_team/help.jinx +52 -0
  95. npcsh-1.1.13.data/data/npcsh/npc_team/init.jinx +41 -0
  96. npcsh-1.1.13.data/data/npcsh/npc_team/jinxs.jinx +32 -0
  97. npcsh-1.1.13.data/data/npcsh/npc_team/kadiefa.npc +3 -0
  98. npcsh-1.1.13.data/data/npcsh/npc_team/kadiefa.png +0 -0
  99. npcsh-1.1.13.data/data/npcsh/npc_team/load_file.jinx +35 -0
  100. npcsh-1.1.13.data/data/npcsh/npc_team/npc-studio.jinx +77 -0
  101. npcsh-1.1.13.data/data/npcsh/npc_team/npcsh.ctx +18 -0
  102. npcsh-1.1.13.data/data/npcsh/npc_team/npcsh_sibiji.png +0 -0
  103. npcsh-1.1.13.data/data/npcsh/npc_team/ots.jinx +61 -0
  104. npcsh-1.1.13.data/data/npcsh/npc_team/plonk.jinx +214 -0
  105. npcsh-1.1.13.data/data/npcsh/npc_team/plonk.npc +2 -0
  106. npcsh-1.1.13.data/data/npcsh/npc_team/plonk.png +0 -0
  107. npcsh-1.1.13.data/data/npcsh/npc_team/plonkjr.npc +2 -0
  108. npcsh-1.1.13.data/data/npcsh/npc_team/plonkjr.png +0 -0
  109. npcsh-1.1.13.data/data/npcsh/npc_team/pti.jinx +170 -0
  110. npcsh-1.1.13.data/data/npcsh/npc_team/python.jinx +11 -0
  111. npcsh-1.1.13.data/data/npcsh/npc_team/roll.jinx +68 -0
  112. npcsh-1.1.13.data/data/npcsh/npc_team/sample.jinx +56 -0
  113. npcsh-1.1.13.data/data/npcsh/npc_team/search.jinx +130 -0
  114. npcsh-1.1.13.data/data/npcsh/npc_team/serve.jinx +26 -0
  115. npcsh-1.1.13.data/data/npcsh/npc_team/set.jinx +40 -0
  116. npcsh-1.1.13.data/data/npcsh/npc_team/sh.jinx +34 -0
  117. npcsh-1.1.13.data/data/npcsh/npc_team/sibiji.npc +3 -0
  118. npcsh-1.1.13.data/data/npcsh/npc_team/sibiji.png +0 -0
  119. npcsh-1.1.13.data/data/npcsh/npc_team/sleep.jinx +116 -0
  120. npcsh-1.1.13.data/data/npcsh/npc_team/spool.jinx +161 -0
  121. npcsh-1.1.13.data/data/npcsh/npc_team/spool.png +0 -0
  122. npcsh-1.1.13.data/data/npcsh/npc_team/sql.jinx +16 -0
  123. npcsh-1.1.13.data/data/npcsh/npc_team/trigger.jinx +61 -0
  124. npcsh-1.1.13.data/data/npcsh/npc_team/usage.jinx +33 -0
  125. npcsh-1.1.13.data/data/npcsh/npc_team/vixynt.jinx +144 -0
  126. npcsh-1.1.13.data/data/npcsh/npc_team/wander.jinx +186 -0
  127. npcsh-1.1.13.data/data/npcsh/npc_team/yap.jinx +262 -0
  128. npcsh-1.1.13.data/data/npcsh/npc_team/yap.png +0 -0
  129. npcsh-1.1.13.dist-info/METADATA +522 -0
  130. npcsh-1.1.13.dist-info/RECORD +135 -0
  131. {npcsh-0.1.2.dist-info → npcsh-1.1.13.dist-info}/WHEEL +1 -1
  132. npcsh-1.1.13.dist-info/entry_points.txt +9 -0
  133. {npcsh-0.1.2.dist-info → npcsh-1.1.13.dist-info/licenses}/LICENSE +1 -1
  134. npcsh/command_history.py +0 -81
  135. npcsh/helpers.py +0 -36
  136. npcsh/llm_funcs.py +0 -295
  137. npcsh/main.py +0 -5
  138. npcsh/modes.py +0 -343
  139. npcsh/npc_compiler.py +0 -124
  140. npcsh-0.1.2.dist-info/METADATA +0 -99
  141. npcsh-0.1.2.dist-info/RECORD +0 -14
  142. npcsh-0.1.2.dist-info/entry_points.txt +0 -2
  143. {npcsh-0.1.2.dist-info → npcsh-1.1.13.dist-info}/top_level.txt +0 -0
npcsh/alicanto.py ADDED
@@ -0,0 +1,65 @@
1
+ """
2
+ alicanto - Deep research mode CLI entry point
3
+
4
+ This is a thin wrapper that executes the alicanto.jinx through the jinx mechanism.
5
+ """
6
+ import argparse
7
+ import os
8
+ import sys
9
+
10
+ from npcsh._state import setup_shell
11
+
12
+
13
+ def main():
14
+ parser = argparse.ArgumentParser(description="alicanto - Deep research with multiple perspectives")
15
+ parser.add_argument("query", nargs="*", help="Research query")
16
+ parser.add_argument("--model", "-m", type=str, help="LLM model to use")
17
+ parser.add_argument("--provider", "-p", type=str, help="LLM provider to use")
18
+ parser.add_argument("--num-npcs", type=int, default=5, help="Number of research perspectives")
19
+ parser.add_argument("--depth", type=int, default=3, help="Research depth")
20
+ parser.add_argument("--max-steps", type=int, default=20, help="Maximum research steps")
21
+ parser.add_argument("--exploration", type=float, default=0.3, help="Exploration factor (0-1)")
22
+ parser.add_argument("--creativity", type=float, default=0.5, help="Creativity factor (0-1)")
23
+ parser.add_argument("--format", type=str, default="report", choices=["report", "summary", "full"],
24
+ help="Output format")
25
+ parser.add_argument("--with-research", action="store_true", help="Include web research")
26
+ args = parser.parse_args()
27
+
28
+ if not args.query:
29
+ parser.print_help()
30
+ sys.exit(1)
31
+
32
+ # Setup shell to get team and default NPC
33
+ command_history, team, default_npc = setup_shell()
34
+
35
+ if not team or "alicanto" not in team.jinxs_dict:
36
+ print("Error: alicanto jinx not found. Ensure npc_team/jinxs/modes/alicanto.jinx exists.")
37
+ sys.exit(1)
38
+
39
+ # Build context for jinx execution
40
+ context = {
41
+ "npc": default_npc,
42
+ "team": team,
43
+ "messages": [],
44
+ "query": " ".join(args.query),
45
+ "model": args.model,
46
+ "provider": args.provider,
47
+ "num_npcs": args.num_npcs,
48
+ "depth": args.depth,
49
+ "max_steps": args.max_steps,
50
+ "exploration": args.exploration,
51
+ "creativity": args.creativity,
52
+ "format": args.format,
53
+ "skip_research": not args.with_research,
54
+ }
55
+
56
+ # Execute the jinx
57
+ alicanto_jinx = team.jinxs_dict["alicanto"]
58
+ result = alicanto_jinx.execute(context=context, npc=default_npc)
59
+
60
+ if isinstance(result, dict) and result.get("output"):
61
+ print(result["output"])
62
+
63
+
64
+ if __name__ == "__main__":
65
+ main()
npcsh/build.py ADDED
@@ -0,0 +1,291 @@
1
+ import os
2
+ import shutil
3
+ import textwrap
4
+ from pathlib import Path
5
+
6
+
7
+ def build_flask_server(config, **kwargs):
8
+ output_dir = Path(config['output_dir'])
9
+ output_dir.mkdir(parents=True, exist_ok=True)
10
+
11
+ server_script = output_dir / 'npc_server.py'
12
+
13
+ server_code = textwrap.dedent(f'''
14
+ import os
15
+ from npcpy.serve import start_flask_server
16
+ from npcpy.npc_compiler import Team
17
+ from sqlalchemy import create_engine
18
+
19
+ if __name__ == "__main__":
20
+ team_path = os.path.join(
21
+ os.path.dirname(__file__),
22
+ "npc_team"
23
+ )
24
+ db_path = os.path.expanduser("~/npcsh_history.db")
25
+
26
+ db_conn = create_engine(f'sqlite:///{{db_path}}')
27
+ team = Team(team_path=team_path, db_conn=db_conn)
28
+
29
+ start_flask_server(
30
+ port={config['port']},
31
+ cors_origins={config.get('cors_origins')},
32
+ teams={{"main": team}},
33
+ npcs=team.npcs,
34
+ db_path=db_path,
35
+ user_npc_directory=os.path.expanduser(
36
+ "~/.npcsh/npc_team"
37
+ )
38
+ )
39
+ ''')
40
+
41
+ server_script.write_text(server_code)
42
+
43
+ shutil.copytree(
44
+ config['team_path'],
45
+ output_dir / 'npc_team',
46
+ dirs_exist_ok=True
47
+ )
48
+
49
+ requirements = output_dir / 'requirements.txt'
50
+ requirements.write_text(
51
+ 'npcsh\n'
52
+ 'flask\n'
53
+ 'flask-cors\n'
54
+ 'sqlalchemy\n'
55
+ )
56
+
57
+ readme = output_dir / 'README.md'
58
+ readme.write_text(textwrap.dedent(f'''
59
+ # NPC Team Server
60
+
61
+ Run: python npc_server.py
62
+
63
+ Server will be available at http://localhost:{config['port']}
64
+
65
+ For pyinstaller standalone:
66
+ pyinstaller --onefile npc_server.py
67
+ '''))
68
+
69
+ return {
70
+ "output": f"Flask server built in {output_dir}",
71
+ "messages": kwargs.get('messages', [])
72
+ }
73
+
74
+
75
+ def build_docker_compose(config, **kwargs):
76
+ output_dir = Path(config['output_dir'])
77
+ output_dir.mkdir(parents=True, exist_ok=True)
78
+
79
+ shutil.copytree(
80
+ config['team_path'],
81
+ output_dir / 'npc_team',
82
+ dirs_exist_ok=True
83
+ )
84
+
85
+ dockerfile = output_dir / 'Dockerfile'
86
+ dockerfile.write_text(textwrap.dedent('''
87
+ FROM python:3.11-slim
88
+
89
+ WORKDIR /app
90
+
91
+ COPY requirements.txt .
92
+ RUN pip install --no-cache-dir -r requirements.txt
93
+
94
+ COPY npc_team ./npc_team
95
+ COPY npc_server.py .
96
+
97
+ EXPOSE 5337
98
+
99
+ CMD ["python", "npc_server.py"]
100
+ '''))
101
+
102
+ compose = output_dir / 'docker-compose.yml'
103
+ compose.write_text(textwrap.dedent(f'''
104
+ version: '3.8'
105
+
106
+ services:
107
+ npc-server:
108
+ build: .
109
+ ports:
110
+ - "{config['port']}:{config['port']}"
111
+ volumes:
112
+ - npc-data:/root/.npcsh
113
+ environment:
114
+ - NPCSH_DB_PATH=/root/.npcsh/npcsh_history.db
115
+
116
+ volumes:
117
+ npc-data:
118
+ '''))
119
+
120
+ build_flask_server(config, **kwargs)
121
+
122
+ return {
123
+ "output": f"Docker compose built in {output_dir}. Run: docker-compose up",
124
+ "messages": kwargs.get('messages', [])
125
+ }
126
+
127
+
128
+ def build_cli_executable(config, **kwargs):
129
+ output_dir = Path(config['output_dir'])
130
+ output_dir.mkdir(parents=True, exist_ok=True)
131
+
132
+ cli_script = output_dir / 'npc_cli.py'
133
+
134
+ cli_code = textwrap.dedent('''
135
+ import sys
136
+ from npcsh._state import setup_shell, execute_command, initial_state
137
+ from npcsh.routes import router
138
+
139
+ def main():
140
+ if len(sys.argv) < 2:
141
+ print("Usage: npc_cli <command>")
142
+ sys.exit(1)
143
+
144
+ command = " ".join(sys.argv[1:])
145
+
146
+ command_history, team, npc = setup_shell()
147
+ initial_state.npc = npc
148
+ initial_state.team = team
149
+
150
+ state, result = execute_command(
151
+ command,
152
+ initial_state,
153
+ router=router
154
+ )
155
+
156
+ output = result.get('output') if isinstance(result, dict) else result
157
+ print(output)
158
+
159
+ if __name__ == "__main__":
160
+ main()
161
+ ''')
162
+
163
+ cli_script.write_text(cli_code)
164
+
165
+ shutil.copytree(
166
+ config['team_path'],
167
+ output_dir / 'npc_team',
168
+ dirs_exist_ok=True
169
+ )
170
+
171
+ spec_file = output_dir / 'npc_cli.spec'
172
+ spec_file.write_text(textwrap.dedent('''
173
+ a = Analysis(
174
+ ['npc_cli.py'],
175
+ pathex=[],
176
+ binaries=[],
177
+ datas=[('npc_team', 'npc_team')],
178
+ hiddenimports=[],
179
+ hookspath=[],
180
+ hooksconfig={},
181
+ runtime_hooks=[],
182
+ excludes=[],
183
+ win_no_prefer_redirects=False,
184
+ win_private_assemblies=False,
185
+ cipher=None,
186
+ noarchive=False,
187
+ )
188
+ pyz = PYZ(a.pure, a.zipped_data, cipher=None)
189
+
190
+ exe = EXE(
191
+ pyz,
192
+ a.scripts,
193
+ a.binaries,
194
+ a.zipfiles,
195
+ a.datas,
196
+ [],
197
+ name='npc',
198
+ debug=False,
199
+ bootloader_ignore_signals=False,
200
+ strip=False,
201
+ upx=True,
202
+ upx_exclude=[],
203
+ runtime_tmpdir=None,
204
+ console=True,
205
+ )
206
+ '''))
207
+
208
+ return {
209
+ "output": (
210
+ f"CLI executable built in {output_dir}. "
211
+ f"Run: pyinstaller npc_cli.spec"
212
+ ),
213
+ "messages": kwargs.get('messages', [])
214
+ }
215
+
216
+
217
+ def build_static_site(config, **kwargs):
218
+ output_dir = Path(config['output_dir'])
219
+ output_dir.mkdir(parents=True, exist_ok=True)
220
+
221
+ html = output_dir / 'index.html'
222
+ html.write_text(textwrap.dedent(f'''
223
+ <!DOCTYPE html>
224
+ <html>
225
+ <head>
226
+ <title>NPC Team Interface</title>
227
+ <style>
228
+ body {{
229
+ font-family: monospace;
230
+ max-width: 800px;
231
+ margin: 50px auto;
232
+ }}
233
+ #output {{
234
+ white-space: pre-wrap;
235
+ background: #f5f5f5;
236
+ padding: 20px;
237
+ min-height: 300px;
238
+ }}
239
+ </style>
240
+ </head>
241
+ <body>
242
+ <h1>NPC Team</h1>
243
+ <input id="input" type="text"
244
+ placeholder="Enter command..."
245
+ style="width: 100%; padding: 10px;">
246
+ <div id="output"></div>
247
+
248
+ <script>
249
+ const API_URL = '{config.get("api_url", "http://localhost:5337")}';
250
+
251
+ document.getElementById('input').addEventListener('keypress',
252
+ async (e) => {{
253
+ if (e.key === 'Enter') {{
254
+ const cmd = e.target.value;
255
+ e.target.value = '';
256
+
257
+ const resp = await fetch(`${{API_URL}}/api/stream`, {{
258
+ method: 'POST',
259
+ headers: {{'Content-Type': 'application/json'}},
260
+ body: JSON.stringify({{
261
+ commandstr: cmd,
262
+ conversationId: 'web-session',
263
+ model: 'llama3.2',
264
+ provider: 'ollama'
265
+ }})
266
+ }});
267
+
268
+ const reader = resp.body.getReader();
269
+ const decoder = new TextDecoder();
270
+
271
+ while (true) {{
272
+ const {{done, value}} = await reader.read();
273
+ if (done) break;
274
+
275
+ const text = decoder.decode(value);
276
+ document.getElementById('output').textContent += text;
277
+ }}
278
+ }}
279
+ }});
280
+ </script>
281
+ </body>
282
+ </html>
283
+ '''))
284
+
285
+ return {
286
+ "output": (
287
+ f"Static site built in {output_dir}. "
288
+ f"Serve with: python -m http.server 8000"
289
+ ),
290
+ "messages": kwargs.get('messages', [])
291
+ }
npcsh/completion.py ADDED
@@ -0,0 +1,206 @@
1
+ """
2
+ Readline and tab completion for npcsh
3
+ """
4
+ import os
5
+ import shutil
6
+ from typing import List, Any, Optional
7
+
8
+ try:
9
+ import readline
10
+ except ImportError:
11
+ readline = None
12
+
13
+ from .config import READLINE_HISTORY_FILE
14
+
15
+
16
+ def setup_readline() -> str:
17
+ """Set up readline with history and completion"""
18
+ if readline is None:
19
+ return ""
20
+
21
+ history_file = READLINE_HISTORY_FILE
22
+
23
+ try:
24
+ readline.read_history_file(history_file)
25
+ except FileNotFoundError:
26
+ pass
27
+
28
+ readline.set_history_length(10000)
29
+ readline.parse_and_bind("tab: complete")
30
+
31
+ return history_file
32
+
33
+
34
+ def save_readline_history():
35
+ """Save readline history to file"""
36
+ if readline is None:
37
+ return
38
+
39
+ try:
40
+ readline.write_history_file(READLINE_HISTORY_FILE)
41
+ except Exception:
42
+ pass
43
+
44
+
45
+ def get_path_executables() -> List[str]:
46
+ """Get list of executables in PATH"""
47
+ executables = set()
48
+
49
+ path_dirs = os.environ.get("PATH", "").split(os.pathsep)
50
+
51
+ for path_dir in path_dirs:
52
+ if os.path.isdir(path_dir):
53
+ try:
54
+ for entry in os.listdir(path_dir):
55
+ full_path = os.path.join(path_dir, entry)
56
+ if os.access(full_path, os.X_OK):
57
+ executables.add(entry)
58
+ except PermissionError:
59
+ pass
60
+
61
+ return sorted(executables)
62
+
63
+
64
+ def get_file_completions(text: str) -> List[str]:
65
+ """Get file/directory completions for text"""
66
+ completions = []
67
+
68
+ if text.startswith("~"):
69
+ expanded = os.path.expanduser(text)
70
+ prefix = "~"
71
+ search_path = expanded
72
+ else:
73
+ prefix = ""
74
+ search_path = text
75
+
76
+ # Get directory to search
77
+ if os.path.isdir(search_path):
78
+ dir_path = search_path
79
+ name_prefix = ""
80
+ else:
81
+ dir_path = os.path.dirname(search_path) or "."
82
+ name_prefix = os.path.basename(search_path)
83
+
84
+ if not os.path.isdir(dir_path):
85
+ return completions
86
+
87
+ try:
88
+ for entry in os.listdir(dir_path):
89
+ if entry.startswith(name_prefix):
90
+ full_path = os.path.join(dir_path, entry)
91
+ if os.path.isdir(full_path):
92
+ completions.append(entry + "/")
93
+ else:
94
+ completions.append(entry)
95
+ except PermissionError:
96
+ pass
97
+
98
+ return completions
99
+
100
+
101
+ def get_slash_commands(state: Any, router: Any) -> List[str]:
102
+ """Get list of available slash commands"""
103
+ commands = set()
104
+
105
+ # Built-in commands and modes
106
+ commands.update([
107
+ '/help', '/set', '/agent', '/chat', '/cmd',
108
+ '/sq', '/quit', '/exit', '/clear',
109
+ ])
110
+
111
+ # Team jinxs
112
+ if state.team and hasattr(state.team, 'jinxs_dict'):
113
+ for name in state.team.jinxs_dict:
114
+ commands.add(f'/{name}')
115
+
116
+ # Router jinxs
117
+ if router and hasattr(router, 'jinx_routes'):
118
+ for name in router.jinx_routes:
119
+ commands.add(f'/{name}')
120
+
121
+ return sorted(commands)
122
+
123
+
124
+ def get_npc_mentions(state: Any) -> List[str]:
125
+ """Get list of available @npc mentions"""
126
+ npcs = set()
127
+
128
+ # Team NPCs
129
+ if state.team and hasattr(state.team, 'npcs'):
130
+ for name in state.team.npcs:
131
+ npcs.add(f'@{name}')
132
+
133
+ # Also add forenpc if available
134
+ if state.team and hasattr(state.team, 'forenpc') and state.team.forenpc:
135
+ npcs.add(f'@{state.team.forenpc.name}')
136
+
137
+ # Default NPCs if team not loaded yet
138
+ if not npcs:
139
+ npcs.update(['@sibiji', '@guac', '@corca', '@kadiefa', '@plonk', '@forenpc'])
140
+
141
+ return sorted(npcs)
142
+
143
+
144
+ def is_command_position(buffer: str, begidx: int) -> bool:
145
+ """Check if we're completing a command (vs argument)"""
146
+ # If we're at the start or after a pipe, it's command position
147
+ before = buffer[:begidx].strip()
148
+ return not before or before.endswith('|')
149
+
150
+
151
+ def make_completer(shell_state: Any, router: Any):
152
+ """Create a completer function for readline"""
153
+
154
+ executables = get_path_executables()
155
+
156
+ def completer(text: str, state: int):
157
+ if readline is None:
158
+ return None
159
+
160
+ try:
161
+ buffer = readline.get_line_buffer()
162
+ begidx = readline.get_begidx()
163
+
164
+ # Build completion options
165
+ options = []
166
+
167
+ # Refresh slash commands and NPC mentions each time (they may change)
168
+ slash_commands = get_slash_commands(shell_state, router)
169
+ npc_mentions = get_npc_mentions(shell_state)
170
+
171
+ if text.startswith('/'):
172
+ # Slash command completion
173
+ options = [c for c in slash_commands if c.startswith(text)]
174
+
175
+ elif text.startswith('@'):
176
+ # @npc mention completion
177
+ options = [n for n in npc_mentions if n.startswith(text)]
178
+
179
+ elif text.startswith('~') or '/' in text or text.startswith('.'):
180
+ # File path completion
181
+ options = get_file_completions(text)
182
+
183
+ elif is_command_position(buffer, begidx):
184
+ # Command completion
185
+ options = [e for e in executables if e.startswith(text)]
186
+
187
+ else:
188
+ # Default to file completion
189
+ options = get_file_completions(text)
190
+
191
+ if state < len(options):
192
+ return options[state]
193
+ return None
194
+
195
+ except Exception:
196
+ return None
197
+
198
+ return completer
199
+
200
+
201
+ def readline_safe_prompt(prompt: str) -> str:
202
+ """Make prompt safe for readline (escape ANSI codes)"""
203
+ if readline is None:
204
+ return prompt
205
+ # Wrap non-printing characters
206
+ return prompt.replace('\x1b[', '\x01\x1b[').replace('m', 'm\x02')