nightfall-cli 0.1.0__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.
nightfall/todos.py ADDED
@@ -0,0 +1,66 @@
1
+ """The plan. Lives here, not in the transcript, and is re-injected every turn."""
2
+
3
+ MARKS = {"pending": "[ ]", "in_progress": "[~]", "done": "[x]"}
4
+
5
+ TODOS = [] # [{"content": ..., "activeForm": ..., "status": ...}]
6
+
7
+
8
+ def write_todos(todos):
9
+ """Replace the whole list. Exactly one task may be in_progress."""
10
+ active = [t for t in todos if t["status"] == "in_progress"]
11
+ if len(active) > 1:
12
+ return f"Error: {len(active)} tasks are in_progress. Only one may be."
13
+
14
+ TODOS[:] = todos
15
+ return todos_prompt() or "Todo list cleared."
16
+
17
+
18
+ def todos_prompt():
19
+ return "\n".join(f"{MARKS[t['status']]} {t['content']}" for t in TODOS)
20
+
21
+
22
+ def active_form():
23
+ """What the agent is doing right now, for the spinner."""
24
+ for todo in TODOS:
25
+ if todo["status"] == "in_progress":
26
+ return todo["activeForm"]
27
+ return "thinking"
28
+
29
+
30
+ TODO_SCHEMA = {
31
+ "type": "function",
32
+ "function": {
33
+ "name": "write_todos",
34
+ "description": (
35
+ "Record the plan for a multi-step task. Send the whole list every "
36
+ "time. Keep exactly one task in_progress and update it as you go."
37
+ ),
38
+ "parameters": {
39
+ "type": "object",
40
+ "properties": {
41
+ "todos": {
42
+ "type": "array",
43
+ "items": {
44
+ "type": "object",
45
+ "properties": {
46
+ "content": {
47
+ "type": "string",
48
+ "description": "The task, imperative: 'Fix the parser'",
49
+ },
50
+ "activeForm": {
51
+ "type": "string",
52
+ "description": "Present continuous: 'Fixing the parser'",
53
+ },
54
+ "status": {
55
+ "type": "string",
56
+ "enum": ["pending", "in_progress", "done"],
57
+ },
58
+ },
59
+ "required": ["content", "activeForm", "status"],
60
+ },
61
+ }
62
+ },
63
+ "required": ["todos"],
64
+ },
65
+ },
66
+ }
nightfall/tools.py ADDED
@@ -0,0 +1,198 @@
1
+ import json
2
+ import subprocess
3
+
4
+ from . import history
5
+ from . import sandbox
6
+ from .permissions import check
7
+ from .subagent import TASK_SCHEMA, task
8
+ from .skills import read_skill
9
+ from .todos import TODO_SCHEMA, write_todos
10
+
11
+
12
+ def bash(command: str) -> str:
13
+ """Run a shell command and return its combined stdout and stderr."""
14
+ try:
15
+ result = sandbox.run(command)
16
+ except subprocess.TimeoutExpired as expired:
17
+ # Hand the failure back as a result. A slow command is the model's
18
+ # problem to work around, not a reason to take the session down.
19
+ return (
20
+ f"Timed out after {expired.timeout}s and was killed. "
21
+ "Narrow it down - search inside the working directory rather than /."
22
+ )
23
+ return history.cap((result.stdout + result.stderr) or "(no output)")
24
+
25
+
26
+ def read_file(path: str) -> str:
27
+ """Read a file and return its contents."""
28
+ with open(path) as f:
29
+ return history.cap(f.read())
30
+
31
+
32
+ def write_file(path: str, content: str) -> str:
33
+ """Create a file, or overwrite it if it already exists."""
34
+ with open(path, "w") as f:
35
+ f.write(content)
36
+ return f"Wrote {path}"
37
+
38
+
39
+ def str_replace(path, old_str, new_str, allow_multi_edit=False):
40
+ """Swap exact text in a file. old_str must match exactly once."""
41
+ with open(path) as f:
42
+ content = f.read()
43
+
44
+ count = content.count(old_str)
45
+ if count == 0:
46
+ return f"Error: old_str was not found in {path}"
47
+ if count > 1 and not allow_multi_edit:
48
+ return (
49
+ f"Error: old_str matches {count} times in {path}. "
50
+ "Add surrounding lines to make it unique, "
51
+ "or set allow_multi_edit to replace them all."
52
+ )
53
+
54
+ with open(path, "w") as f:
55
+ f.write(content.replace(old_str, new_str))
56
+ return f"Replaced {count} match(es) in {path}"
57
+
58
+
59
+ def execute(tool_call):
60
+ """Run one tool call through the permission layer.
61
+
62
+ Shared by the main loop and by subagents, so a subagent is fenced in by
63
+ exactly the same rules - it is not a way around them.
64
+
65
+ A tool call is text the model wrote, so all of it is untrusted: the name
66
+ may not exist, the arguments may not be JSON, and they may not match the
67
+ signature. Every one of those comes back as a result the model can read
68
+ and retry. None of them is allowed to end the session.
69
+ """
70
+ from .ui import ui
71
+
72
+ name = tool_call.function.name
73
+ try:
74
+ args = json.loads(tool_call.function.arguments)
75
+ except json.JSONDecodeError as broken:
76
+ return {}, f"Error: arguments were not valid JSON ({broken})."
77
+
78
+ if name not in TOOLS:
79
+ return args, f"Error: no tool named '{name}'. Available: {', '.join(TOOLS)}."
80
+
81
+ try:
82
+ action, reason = check(name, args)
83
+ if action == "deny":
84
+ return args, f"Blocked by policy: {reason}"
85
+ if action == "ask" and not ui.approve(reason):
86
+ return args, "The user denied this tool call."
87
+ return args, TOOLS[name](**args)
88
+ except TypeError as mismatch:
89
+ return args, f"Error: wrong arguments for {name} ({mismatch})."
90
+ except KeyError as missing:
91
+ return args, f"Error: {name} needs an argument you did not send: {missing}."
92
+ except Exception as failure: # noqa: BLE001 - the model gets to see and retry
93
+ return args, f"Error: {name} failed - {type(failure).__name__}: {failure}"
94
+
95
+
96
+ TOOL_SCHEMAS = [
97
+ {
98
+ "type": "function",
99
+ "function": {
100
+ "name": "bash",
101
+ "description": "Run a shell command and return its combined stdout and stderr.",
102
+ "parameters": {
103
+ "type": "object",
104
+ "properties": {
105
+ "command": {
106
+ "type": "string",
107
+ "description": "The shell command to run",
108
+ }
109
+ },
110
+ "required": ["command"],
111
+ },
112
+ },
113
+ },
114
+ {
115
+ "type": "function",
116
+ "function": {
117
+ "name": "read_file",
118
+ "description": "Read a file and return its contents.",
119
+ "parameters": {
120
+ "type": "object",
121
+ "properties": {
122
+ "path": {
123
+ "type": "string",
124
+ "description": "Path to the file to read",
125
+ }
126
+ },
127
+ "required": ["path"],
128
+ },
129
+ },
130
+ },
131
+ {
132
+ "type": "function",
133
+ "function": {
134
+ "name": "read_skill",
135
+ "description": "Open a skill by name and return its full instructions.",
136
+ "parameters": {
137
+ "type": "object",
138
+ "properties": {
139
+ "name": {
140
+ "type": "string",
141
+ "description": "Name of the skill to open",
142
+ }
143
+ },
144
+ "required": ["name"],
145
+ },
146
+ },
147
+ },
148
+ {
149
+ "type": "function",
150
+ "function": {
151
+ "name": "write_file",
152
+ "description": "Create a file, or overwrite it if it already exists.",
153
+ "parameters": {
154
+ "type": "object",
155
+ "properties": {
156
+ "path": {"type": "string", "description": "File to write"},
157
+ "content": {"type": "string", "description": "The full contents"},
158
+ },
159
+ "required": ["path", "content"],
160
+ },
161
+ },
162
+ },
163
+ {
164
+ "type": "function",
165
+ "function": {
166
+ "name": "str_replace",
167
+ "description": (
168
+ "Replace exact text in a file. old_str must appear exactly once, "
169
+ "so include surrounding lines if needed."
170
+ ),
171
+ "parameters": {
172
+ "type": "object",
173
+ "properties": {
174
+ "path": {"type": "string", "description": "File to edit"},
175
+ "old_str": {"type": "string", "description": "Exact text to find"},
176
+ "new_str": {"type": "string", "description": "Text to put in its place"},
177
+ "allow_multi_edit": {
178
+ "type": "boolean",
179
+ "description": "Replace every match instead of failing",
180
+ },
181
+ },
182
+ "required": ["path", "old_str", "new_str"],
183
+ },
184
+ },
185
+ },
186
+ TODO_SCHEMA,
187
+ TASK_SCHEMA,
188
+ ]
189
+
190
+ TOOLS = {
191
+ "bash": bash,
192
+ "read_file": read_file,
193
+ "write_file": write_file,
194
+ "str_replace": str_replace,
195
+ "read_skill": read_skill,
196
+ "write_todos": write_todos,
197
+ "task": task,
198
+ }
nightfall/ui.py ADDED
@@ -0,0 +1,290 @@
1
+ """Terminal presentation layer.
2
+
3
+ Knows nothing about LLMs, providers or tools - it only receives plain strings
4
+ and dicts and decides how they look.
5
+ """
6
+
7
+ import json
8
+ from contextlib import contextmanager
9
+
10
+ from rich.console import Console, Group
11
+ from rich.json import JSON
12
+ from rich.markdown import Markdown
13
+ from rich.padding import Padding
14
+ from rich.panel import Panel
15
+ from rich.rule import Rule
16
+ from rich.table import Table
17
+ from rich.text import Text
18
+
19
+ from . import prompt
20
+ from .todos import MARKS
21
+
22
+ ACCENT = "#7aa2f7"
23
+ USER = "#9ece6a"
24
+ TOOL = "#e0af68"
25
+ MUTED = "#565f89"
26
+
27
+ MAX_TOOL_OUTPUT_LINES = 12
28
+
29
+ TODO_STYLES = {
30
+ "done": f"{MUTED} strike",
31
+ "in_progress": f"bold {ACCENT}",
32
+ "pending": MUTED,
33
+ }
34
+
35
+
36
+ class UI:
37
+ def __init__(self):
38
+ self.console = Console()
39
+ self._totals = {}
40
+
41
+ # ---------------------------------------------------------------- input
42
+
43
+ def banner(self, sandbox_name="none"):
44
+ self.console.print()
45
+ self.console.print(
46
+ Rule(Text(" coding agent ", style=f"bold {ACCENT}"), style=MUTED)
47
+ )
48
+ self.console.print(
49
+ Padding(Text(f"sandbox: {sandbox_name} · opt-enter for a newline · ctrl-d to exit", style=MUTED), (0, 0, 0, 2))
50
+ )
51
+
52
+ def clear(self):
53
+ self.console.clear()
54
+
55
+ def resumed(self, messages, label="resumed"):
56
+ turns = sum(1 for m in messages if m["role"] == "user")
57
+ self.console.print(
58
+ Padding(
59
+ Text(f"{label} · {len(messages)} messages · {turns} turns", style=MUTED),
60
+ (0, 0, 0, 2),
61
+ )
62
+ )
63
+
64
+ def replay(self, messages):
65
+ """Redraw a loaded transcript so the screen matches the history."""
66
+ results = {m["tool_call_id"]: m["content"] for m in messages if m["role"] == "tool"}
67
+ for message in messages:
68
+ if message["role"] == "user":
69
+ self.user(message["content"])
70
+ elif message["role"] == "assistant":
71
+ if message.get("content"):
72
+ self.agent(message["content"])
73
+ for call in message.get("tool_calls") or []:
74
+ self.tool(
75
+ call["function"]["name"],
76
+ json.loads(call["function"]["arguments"]),
77
+ results.get(call["id"], ""),
78
+ )
79
+
80
+ def approve(self, reason):
81
+ self.console.print(Padding(Text(reason, style=f"bold {TOOL}"), (1, 0, 0, 2)))
82
+ try:
83
+ answer = prompt.read(" allow? (y/n)> ").strip()
84
+ except (EOFError, KeyboardInterrupt):
85
+ return False
86
+ return answer.lower().startswith("y")
87
+
88
+ def note(self, text):
89
+ self.console.print(Padding(Text(text, style=MUTED), (1, 0, 0, 2)))
90
+
91
+ def pick(self, title, rows):
92
+ """Numbered list; returns the chosen index or None."""
93
+ self.console.print(Padding(Text(title, style=f"bold {ACCENT}"), (1, 0, 0, 2)))
94
+ for i, row in enumerate(rows):
95
+ self.console.print(Padding(Text(f"{i:>3} {row}", style=MUTED), (0, 0, 0, 2)))
96
+ try:
97
+ answer = prompt.read("\n number> ").strip()
98
+ except (EOFError, KeyboardInterrupt):
99
+ return None
100
+ return int(answer) if answer.isdigit() and int(answer) < len(rows) else None
101
+
102
+ def ask(self):
103
+ self.console.print()
104
+ try:
105
+ return prompt.read("> ").strip()
106
+ except (EOFError, KeyboardInterrupt):
107
+ self.console.print()
108
+ return ""
109
+
110
+ # --------------------------------------------------------------- output
111
+
112
+ def user(self, text):
113
+ self.console.print(
114
+ Padding(Text(text.strip(), style=f"bold {USER}"), (1, 0, 0, 2))
115
+ )
116
+
117
+ def agent(self, text):
118
+ self.console.print(
119
+ Padding(
120
+ Group(
121
+ Text("agent", style=f"bold {ACCENT}"),
122
+ Padding(Markdown(text.strip()), (1, 0, 0, 0)),
123
+ ),
124
+ (1, 2, 0, 2),
125
+ )
126
+ )
127
+
128
+ def tool(self, name, args, result, nested=False):
129
+ if name == "write_todos" and args.get("todos"):
130
+ return self.todos(args["todos"])
131
+
132
+ header = Text.assemble(
133
+ (f"{name} ", f"bold {TOOL}"),
134
+ (self._format_args(args), MUTED),
135
+ )
136
+ self.console.print(
137
+ Padding(
138
+ Panel(
139
+ Group(header, Rule(style=MUTED), self._format_result(result)),
140
+ border_style=MUTED,
141
+ padding=(0, 1),
142
+ ),
143
+ (1, 2, 0, 6 if nested else 2),
144
+ )
145
+ )
146
+
147
+ def subagent(self, description):
148
+ """Shown to you, never to the main agent - it only gets the report."""
149
+ self.console.print(
150
+ Padding(
151
+ Panel(
152
+ Text(description.strip(), style=MUTED),
153
+ title=Text("subagent · own context", style=f"bold {ACCENT}"),
154
+ title_align="left",
155
+ border_style=ACCENT,
156
+ padding=(0, 1),
157
+ ),
158
+ (1, 2, 0, 4),
159
+ )
160
+ )
161
+
162
+ def injection(self, text):
163
+ self.console.print(
164
+ Padding(
165
+ Panel(
166
+ Text(text.strip(), style=MUTED),
167
+ title=Text("late injection", style=f"italic {MUTED}"),
168
+ title_align="left",
169
+ border_style=MUTED,
170
+ padding=(0, 1),
171
+ ),
172
+ (1, 2, 0, 2),
173
+ )
174
+ )
175
+
176
+ def debug(self, data):
177
+ self.console.print(
178
+ Padding(
179
+ Panel(
180
+ JSON.from_data(data),
181
+ title=Text("raw response", style=f"italic {MUTED}"),
182
+ title_align="left",
183
+ border_style=TOOL,
184
+ padding=(0, 1),
185
+ ),
186
+ (1, 2, 0, 2),
187
+ )
188
+ )
189
+
190
+ @contextmanager
191
+ def working(self, label="thinking"):
192
+ with self.console.status(
193
+ Text(label, style=MUTED), spinner="dots", spinner_style=ACCENT
194
+ ):
195
+ yield
196
+
197
+ # ---------------------------------------------------------------- usage
198
+
199
+ def usage(self, stats):
200
+ for key, value in stats.items():
201
+ self._totals[key] = self._totals.get(key, 0) + (value or 0)
202
+
203
+ parts = " · ".join(
204
+ f"{value:,} {key.replace('_tokens', '')}"
205
+ for key, value in stats.items()
206
+ if value
207
+ )
208
+ self.console.print(Padding(Text(parts, style=MUTED), (1, 0, 0, 2)))
209
+
210
+ def summary(self):
211
+ if not self._totals:
212
+ return
213
+
214
+ table = Table.grid(padding=(0, 2))
215
+ table.add_column(style=MUTED)
216
+ table.add_column(style=f"bold {ACCENT}", justify="right")
217
+ for key, value in self._totals.items():
218
+ table.add_row(key.replace("_", " "), f"{value:,}")
219
+
220
+ self.console.print(Padding(table, (1, 2)))
221
+ self.console.print(Rule(style=MUTED))
222
+ self.console.print()
223
+
224
+ def compacted(self, before, messages):
225
+ summary = next(
226
+ (m["content"] for m in messages if "<summary>" in (m.get("content") or "")),
227
+ "",
228
+ )
229
+ self.console.print(
230
+ Padding(
231
+ Panel(
232
+ Markdown(summary.replace("<summary>", "").replace("</summary>", "")),
233
+ title=Text(
234
+ f"compacted · {before} → {len(messages)} messages",
235
+ style=f"bold {TOOL}",
236
+ ),
237
+ title_align="left",
238
+ border_style=TOOL,
239
+ padding=(0, 1),
240
+ ),
241
+ (1, 2, 0, 2),
242
+ )
243
+ )
244
+
245
+ def todos(self, todos):
246
+ """The plan, as a checklist. The raw tool output is never worth showing."""
247
+ done = sum(1 for t in todos if t["status"] == "done")
248
+
249
+ rows = Table.grid(padding=(0, 1))
250
+ rows.add_column(no_wrap=True)
251
+ rows.add_column(overflow="fold")
252
+ for todo in todos:
253
+ status = todo["status"]
254
+ style = TODO_STYLES[status]
255
+ rows.add_row(
256
+ Text(MARKS[status], style=style),
257
+ Text(todo["content"], style=style),
258
+ )
259
+
260
+ self.console.print(
261
+ Padding(
262
+ Panel(
263
+ rows,
264
+ title=Text(f"todos {done}/{len(todos)}", style=f"bold {TOOL}"),
265
+ title_align="left",
266
+ border_style=MUTED,
267
+ padding=(0, 1),
268
+ ),
269
+ (1, 2, 0, 2),
270
+ )
271
+ )
272
+
273
+ # -------------------------------------------------------------- helpers
274
+
275
+ def _format_args(self, args):
276
+ if len(args) == 1:
277
+ return str(next(iter(args.values())))
278
+ return json.dumps(args)
279
+
280
+ def _format_result(self, result):
281
+ lines = result.strip().splitlines() or ["(no output)"]
282
+ shown = lines[:MAX_TOOL_OUTPUT_LINES]
283
+ body = Text("\n".join(shown), style=MUTED)
284
+ hidden = len(lines) - len(shown)
285
+ if hidden > 0:
286
+ body.append(f"\n… {hidden} more lines", style=f"italic {TOOL}")
287
+ return body
288
+
289
+
290
+ ui = UI()
@@ -0,0 +1,58 @@
1
+ Metadata-Version: 2.5
2
+ Name: nightfall-cli
3
+ Version: 0.1.0
4
+ Summary: A minimal coding agent
5
+ Requires-Python: >=3.10
6
+ Requires-Dist: openai>=3.5.0
7
+ Requires-Dist: prompt-toolkit>=3.0.53
8
+ Requires-Dist: pyyaml>=6.0.3
9
+ Requires-Dist: rich>=15.0.0
10
+ Description-Content-Type: text/markdown
11
+
12
+ # Nightfall CLI
13
+
14
+ A minimal coding agent harness in Python, built to show how the pieces of a coding agent fit together.
15
+
16
+ This is the Nightfall CLI repository, a small coding-agent harness built from scratch.
17
+
18
+ https://github.com/user-attachments/assets/e4aaa9e4-69ec-40e3-8f5a-e4ec8c5b7208
19
+
20
+ ## Getting started
21
+
22
+ Install the project with [uv](https://docs.astral.sh/uv/):
23
+
24
+ ```bash
25
+ uv sync
26
+ ```
27
+
28
+ Configure an OpenAI-compatible endpoint and key in `~/.agents/env`:
29
+
30
+ ```text
31
+ BASE_URL=https://your-endpoint/v1
32
+ API_KEY=your-api-key
33
+ MODEL=your-model-name
34
+ ```
35
+
36
+ Start the agent from the repository root:
37
+
38
+ ```bash
39
+ uv run nightfall
40
+ ```
41
+
42
+ Use `uv run nightfall --resume` to resume the latest saved session or
43
+ `uv run nightfall --debug` to display raw model responses. The sandbox backend
44
+ is selected automatically for the host operating system.
45
+
46
+
47
+ ## Features
48
+
49
+ - Interactive terminal chat
50
+ - Tools for running shell commands, reading and writing files, and making targeted edits.
51
+ - Configurable model and OpenAI-compatible API endpoint.
52
+ - Tool permissions and shell sandboxing on macOS, Linux, and Windows
53
+ - Skills loaded from project and user `.agents/skills` directories.
54
+ - Subagents for exploring a codebase in a separate context window.
55
+ - Todo tracking for tasks with multiple steps.
56
+ - Saved chat sessions, with `/sessions` to reopen them and `/rewind` to go back in the conversation.
57
+ - Automatic context compaction, plus `/compact` to trigger it manually.
58
+ - Git branch context and reminders when files change between turns.
@@ -0,0 +1,21 @@
1
+ nightfall/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
+ nightfall/agent.py,sha256=Jx5tyhi9rOzC63MdUIfEIu44nu7dVVtNzOyYFisjJWQ,2543
3
+ nightfall/commands.py,sha256=D7eoBloTck9vs79nfCTm3R6fPOvjQ4tst15aj_2O0fM,2389
4
+ nightfall/compact.py,sha256=BQeGUoD4e7HpUtkiWZf8mFvvzz6lnLIVdvzkD6knnmI,4491
5
+ nightfall/config.py,sha256=goO7qzg46ZYA-7O8xcv5Wgc886W426s4yH043q0Zv4w,784
6
+ nightfall/context.py,sha256=ZMrYBpuhQP3LWIBNMCvuEegrkEvcsCJnJ3wumiLtQfA,2008
7
+ nightfall/history.py,sha256=C6C2JRHGctb1zfLEH-zNwK87jhhwN-VRYjl1a6ZIA-g,4449
8
+ nightfall/llm.py,sha256=T4ltsR2iS203xa7Kluy6476BhiK-VS4IKZzZJN_l1Nk,3118
9
+ nightfall/permissions.py,sha256=9M-PztNi_hoAlREndJTvxM0Y6_Q1XzdOWA3WQQ14324,3392
10
+ nightfall/prompt.py,sha256=6gLswlqAflBXaT8LKt3NBARD--I2mfQZSvMKwS1AW10,1848
11
+ nightfall/sandbox.py,sha256=1sC__he9GJKAG6QqSTeBozcMM_BMgg_-P1UzInOwNhI,7272
12
+ nightfall/session.py,sha256=qVmtDUZ9u2c3nBKyB163Oi309IMrojdgvCekwO-rhTE,2655
13
+ nightfall/skills.py,sha256=uzmiDk-sRFUGtwDXQMnaCByiR5mamKaGiq07UHdL8xs,942
14
+ nightfall/subagent.py,sha256=fDB5SzKOkv03LCccJuDw9-Q9K02DwkOpL0ulX4yUb_A,7134
15
+ nightfall/todos.py,sha256=iFmYI-x6mO_I52zIJnWMK8LeoEA3OYH69M190u56uKs,2238
16
+ nightfall/tools.py,sha256=Q_mLxDDWRlCUULaDlN7cs1UTLMQwJQES-SgwQNj1rzQ,6666
17
+ nightfall/ui.py,sha256=-pE-ajCbcWNbembgsbM-RurmModu5fTdpjQIqbhdj5k,9313
18
+ nightfall_cli-0.1.0.dist-info/METADATA,sha256=MF6GGIUUKal7Qn-DONKBCQnUjGh6VZEKFNdM8K5r4Vo,1790
19
+ nightfall_cli-0.1.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
20
+ nightfall_cli-0.1.0.dist-info/entry_points.txt,sha256=RrY9rZc2OVvwiUf3BDJ4AcGkiOt6mtN2uFRd2wxvH8c,51
21
+ nightfall_cli-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ nightfall = nightfall.agent:main