spikuit-cli 0.5.4__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.
@@ -0,0 +1,3 @@
1
+ """Spikuit CLI — spkt command."""
2
+
3
+ __version__ = "0.3.0"
@@ -0,0 +1,17 @@
1
+ """Command modules for the spkt CLI."""
2
+
3
+ from .community import community_app
4
+ from .domain import domain_app
5
+ from .neuron import neuron_app
6
+ from .skills import skills_app
7
+ from .source import source_app
8
+ from .synapse import synapse_app
9
+
10
+ __all__ = [
11
+ "community_app",
12
+ "domain_app",
13
+ "neuron_app",
14
+ "skills_app",
15
+ "source_app",
16
+ "synapse_app",
17
+ ]
@@ -0,0 +1,99 @@
1
+ """Community management commands: spkt community {detect,list}."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import Optional
7
+
8
+ import typer
9
+
10
+ from ..helpers import _extract_title, _get_circuit, _out, _run
11
+
12
+ community_app = typer.Typer(help="Manage graph communities.")
13
+
14
+
15
+ @community_app.command(name="detect")
16
+ def community_detect(
17
+ resolution: float = typer.Option(1.0, "--resolution", "-r", help="Louvain resolution parameter"),
18
+ summarize: bool = typer.Option(False, "--summarize", "-s", help="Generate summary neurons for each community"),
19
+ as_json: bool = typer.Option(False, "--json", help="Output as JSON"),
20
+ brain: Optional[Path] = typer.Option(None, "--brain", "-b", help="Brain root directory"),
21
+ ) -> None:
22
+ """Run community detection on the knowledge graph."""
23
+
24
+ async def _detect():
25
+ circuit = _get_circuit(brain)
26
+ await circuit.connect()
27
+ try:
28
+ result = await circuit.detect_communities(resolution=resolution)
29
+ summaries = []
30
+ if summarize and result:
31
+ summaries = await circuit.generate_community_summaries()
32
+ if as_json:
33
+ out = {
34
+ "detected": True,
35
+ "count": len(result),
36
+ "communities": {str(k): v for k, v in result.items()},
37
+ }
38
+ if summaries:
39
+ out["summaries"] = summaries
40
+ _out(out, use_json=True)
41
+ else:
42
+ if not result:
43
+ typer.echo("No communities detected (empty graph).")
44
+ return
45
+ typer.echo(f"Detected {len(result)} community(ies):")
46
+ for cid, members in sorted(result.items()):
47
+ labels = []
48
+ for nid in members[:5]:
49
+ n = await circuit.get_neuron(nid)
50
+ labels.append(_extract_title(n.content) if n else nid)
51
+ suffix = f" (+{len(members) - 5} more)" if len(members) > 5 else ""
52
+ typer.echo(f" [{cid}] {len(members)} neurons: {', '.join(labels)}{suffix}")
53
+ if summaries:
54
+ typer.echo(f"\nGenerated {len(summaries)} community summary neuron(s).")
55
+ finally:
56
+ await circuit.close()
57
+
58
+ _run(_detect())
59
+
60
+
61
+ @community_app.command(name="list")
62
+ def community_list(
63
+ as_json: bool = typer.Option(False, "--json", help="Output as JSON"),
64
+ brain: Optional[Path] = typer.Option(None, "--brain", "-b", help="Brain root directory"),
65
+ ) -> None:
66
+ """Show current community assignments."""
67
+
68
+ async def _list():
69
+ circuit = _get_circuit(brain)
70
+ await circuit.connect()
71
+ try:
72
+ cmap = circuit.community_map()
73
+ if as_json:
74
+ groups: dict[int, list[str]] = {}
75
+ for nid, cid in cmap.items():
76
+ groups.setdefault(cid, []).append(nid)
77
+ _out({
78
+ "count": len(groups),
79
+ "communities": {str(k): v for k, v in groups.items()},
80
+ }, use_json=True)
81
+ else:
82
+ if not cmap:
83
+ typer.echo("No communities assigned yet. Run: spkt community detect")
84
+ return
85
+ groups = {}
86
+ for nid, cid in cmap.items():
87
+ groups.setdefault(cid, []).append(nid)
88
+ typer.echo(f"{len(groups)} community(ies):")
89
+ for cid, members in sorted(groups.items()):
90
+ labels = []
91
+ for nid in members[:5]:
92
+ n = await circuit.get_neuron(nid)
93
+ labels.append(_extract_title(n.content) if n else nid)
94
+ suffix = f" (+{len(members) - 5} more)" if len(members) > 5 else ""
95
+ typer.echo(f" [{cid}] {len(members)} neurons: {', '.join(labels)}{suffix}")
96
+ finally:
97
+ await circuit.close()
98
+
99
+ _run(_list())
@@ -0,0 +1,151 @@
1
+ """Domain management commands: spkt domain {list,rename,merge}."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import Optional
7
+
8
+ import typer
9
+
10
+ from ..helpers import _get_circuit, _out, _run
11
+
12
+ domain_app = typer.Typer(help="Manage domains.")
13
+
14
+
15
+ @domain_app.command(name="list")
16
+ def domain_list(
17
+ as_json: bool = typer.Option(False, "--json", help="Output as JSON"),
18
+ brain: Optional[Path] = typer.Option(None, "--brain", "-b", help="Brain root directory"),
19
+ ) -> None:
20
+ """List domains with neuron counts."""
21
+
22
+ async def _list():
23
+ circuit = _get_circuit(brain)
24
+ await circuit.connect()
25
+ try:
26
+ counts = await circuit.get_domain_counts()
27
+ if as_json:
28
+ _out(counts, use_json=True)
29
+ else:
30
+ if not counts:
31
+ typer.echo("No domains found.")
32
+ return
33
+ typer.echo("Domains:")
34
+ for c in counts:
35
+ typer.echo(f" {c['domain']:20s} {c['count']} neurons")
36
+ finally:
37
+ await circuit.close()
38
+
39
+ _run(_list())
40
+
41
+
42
+ @domain_app.command(name="rename")
43
+ def domain_rename(
44
+ old: str = typer.Argument(..., help="Current domain name"),
45
+ new: str = typer.Argument(..., help="New domain name"),
46
+ as_json: bool = typer.Option(False, "--json", help="Output as JSON"),
47
+ brain: Optional[Path] = typer.Option(None, "--brain", "-b", help="Brain root directory"),
48
+ ) -> None:
49
+ """Rename a domain (batch update all neurons)."""
50
+
51
+ async def _rename():
52
+ circuit = _get_circuit(brain)
53
+ await circuit.connect()
54
+ try:
55
+ count = await circuit.rename_domain(old, new)
56
+ if as_json:
57
+ _out({"old": old, "new": new, "updated": count}, use_json=True)
58
+ else:
59
+ typer.echo(f"Renamed '{old}' \u2192 '{new}' ({count} neurons updated)")
60
+ finally:
61
+ await circuit.close()
62
+
63
+ _run(_rename())
64
+
65
+
66
+ @domain_app.command(name="merge")
67
+ def domain_merge(
68
+ domains: list[str] = typer.Argument(..., help="Domains to merge"),
69
+ into: str = typer.Option(..., "--into", help="Target domain name"),
70
+ as_json: bool = typer.Option(False, "--json", help="Output as JSON"),
71
+ brain: Optional[Path] = typer.Option(None, "--brain", "-b", help="Brain root directory"),
72
+ ) -> None:
73
+ """Merge multiple domains into one target domain."""
74
+
75
+ async def _merge():
76
+ circuit = _get_circuit(brain)
77
+ await circuit.connect()
78
+ try:
79
+ count = await circuit.merge_domains(domains, into)
80
+ if as_json:
81
+ _out({"merged": domains, "into": into, "updated": count}, use_json=True)
82
+ else:
83
+ typer.echo(f"Merged {domains} \u2192 '{into}' ({count} neurons updated)")
84
+ finally:
85
+ await circuit.close()
86
+
87
+ _run(_merge())
88
+
89
+
90
+ @domain_app.command(name="audit")
91
+ def domain_audit(
92
+ as_json: bool = typer.Option(False, "--json", help="Output as JSON"),
93
+ brain: Optional[Path] = typer.Option(None, "--brain", "-b", help="Brain root directory"),
94
+ ) -> None:
95
+ """Analyze domain ↔ community alignment and suggest actions."""
96
+
97
+ async def _audit():
98
+ circuit = _get_circuit(brain)
99
+ await circuit.connect()
100
+ try:
101
+ result = await circuit.domain_audit()
102
+ if as_json:
103
+ _out(result, use_json=True)
104
+ else:
105
+ domains = result["domains"]
106
+ suggestions = result["suggestions"]
107
+ keywords = result["community_keywords"]
108
+
109
+ if not domains:
110
+ typer.echo("No domains found. Run 'spkt community detect' first.")
111
+ return
112
+
113
+ typer.echo("Domain ↔ Community Alignment\n")
114
+ for d in domains:
115
+ comms = ", ".join(
116
+ f"c{c['community_id']} ({c['count']})"
117
+ for c in d["communities"]
118
+ )
119
+ typer.echo(f" {d['domain']:20s} {d['neuron_count']} neurons [{comms}]")
120
+
121
+ if keywords:
122
+ typer.echo("\nCommunity Keywords:")
123
+ for cid, kws in sorted(keywords.items(), key=lambda x: x[0]):
124
+ if kws:
125
+ typer.echo(f" c{cid}: {', '.join(kws)}")
126
+
127
+ if suggestions:
128
+ typer.echo(f"\nSuggestions ({len(suggestions)}):")
129
+ for s in suggestions:
130
+ if s["action"] == "split":
131
+ comms_str = ", ".join(
132
+ f"c{c['community_id']} ({c['count']} neurons, keywords: {', '.join(c.get('keywords', []))})"
133
+ for c in s["communities"]
134
+ )
135
+ typer.echo(f" SPLIT '{s['domain']}': spans {len(s['communities'])} communities")
136
+ typer.echo(f" {comms_str}")
137
+ elif s["action"] == "merge":
138
+ doms = ", ".join(
139
+ f"{d['domain']} ({d['count']})"
140
+ for d in s["domains"]
141
+ )
142
+ typer.echo(f" MERGE in c{s['community_id']}: {doms}")
143
+ kws = s.get("keywords", [])
144
+ if kws:
145
+ typer.echo(f" suggested name hint: {', '.join(kws)}")
146
+ else:
147
+ typer.echo("\nNo alignment issues detected.")
148
+ finally:
149
+ await circuit.close()
150
+
151
+ _run(_audit())
@@ -0,0 +1,348 @@
1
+ """Neuron management commands: spkt neuron {add,list,inspect,remove,merge,due,fire}."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import datetime, timezone
6
+ from pathlib import Path
7
+ from typing import Optional
8
+
9
+ import typer
10
+
11
+ from spikuit_core import Grade, Neuron, Source, Spike
12
+
13
+ from ..helpers import (
14
+ _GRADE_MAP,
15
+ _extract_title,
16
+ _get_circuit,
17
+ _neuron_dict,
18
+ _out,
19
+ _run,
20
+ )
21
+
22
+ neuron_app = typer.Typer(help="Manage neurons.")
23
+
24
+
25
+ @neuron_app.command(name="add")
26
+ def neuron_add(
27
+ content: str = typer.Argument(..., help="Markdown content for the neuron"),
28
+ type: Optional[str] = typer.Option(None, "--type", "-t", help="Neuron type"),
29
+ domain: Optional[str] = typer.Option(None, "--domain", "-d", help="Domain tag"),
30
+ source_url: Optional[str] = typer.Option(None, "--source-url", help="Source URL for citation"),
31
+ source_title: Optional[str] = typer.Option(None, "--source-title", help="Source title"),
32
+ as_json: bool = typer.Option(False, "--json", help="Output as JSON"),
33
+ brain: Optional[Path] = typer.Option(None, "--brain", "-b", help="Brain root directory"),
34
+ ) -> None:
35
+ """Add a new Neuron to the circuit."""
36
+
37
+ async def _add():
38
+ circuit = _get_circuit(brain)
39
+ await circuit.connect()
40
+ try:
41
+ real_content = content.encode().decode("unicode_escape")
42
+ neuron = Neuron.create(real_content, type=type, domain=domain)
43
+ await circuit.add_neuron(neuron)
44
+
45
+ # Attach source if URL provided
46
+ source_attached = None
47
+ if source_url:
48
+ existing = await circuit.find_source_by_url(source_url)
49
+ if existing:
50
+ await circuit.attach_source(neuron.id, existing.id)
51
+ source_attached = existing
52
+ else:
53
+ src = Source(url=source_url, title=source_title)
54
+ await circuit.add_source(src)
55
+ await circuit.attach_source(neuron.id, src.id)
56
+ source_attached = src
57
+
58
+ if as_json:
59
+ d = _neuron_dict(neuron, circuit)
60
+ if source_attached:
61
+ d["source_id"] = source_attached.id
62
+ d["source_url"] = source_attached.url
63
+ _out(d, use_json=True)
64
+ else:
65
+ typer.echo(f"Added neuron {neuron.id}")
66
+ if source_attached:
67
+ typer.echo(f" source: {source_attached.id} ({source_attached.url})")
68
+ finally:
69
+ await circuit.close()
70
+
71
+ _run(_add())
72
+
73
+
74
+ @neuron_app.command(name="list")
75
+ def neuron_list(
76
+ type: Optional[str] = typer.Option(None, "--type", "-t", help="Filter by type"),
77
+ domain: Optional[str] = typer.Option(None, "--domain", "-d", help="Filter by domain"),
78
+ limit: int = typer.Option(50, "--limit", "-n", help="Max neurons to show"),
79
+ meta_keys: bool = typer.Option(False, "--meta-keys", help="List filterable/searchable metadata keys"),
80
+ meta_values: Optional[str] = typer.Option(None, "--meta-values", help="List distinct values for a metadata key"),
81
+ as_json: bool = typer.Option(False, "--json", help="Output as JSON"),
82
+ brain: Optional[Path] = typer.Option(None, "--brain", "-b", help="Brain root directory"),
83
+ ) -> None:
84
+ """List neurons, or query metadata keys/values."""
85
+
86
+ async def _list():
87
+ circuit = _get_circuit(brain)
88
+ await circuit.connect()
89
+ try:
90
+ # Meta-key discovery mode
91
+ if meta_keys:
92
+ keys = await circuit.get_meta_keys()
93
+ if as_json:
94
+ _out(keys, use_json=True)
95
+ else:
96
+ if not keys:
97
+ typer.echo("No metadata keys found.")
98
+ return
99
+ typer.echo("Metadata keys:")
100
+ for k in keys:
101
+ samples = ", ".join(k["sample_values"][:3])
102
+ typer.echo(f" {k['key']} [{k['layer']}] ({k['count']} sources) e.g. {samples}")
103
+ return
104
+
105
+ # Meta-values mode
106
+ if meta_values:
107
+ values = await circuit.get_meta_values(meta_values)
108
+ if as_json:
109
+ _out(values, use_json=True)
110
+ else:
111
+ if not values:
112
+ typer.echo(f"No values found for key '{meta_values}'.")
113
+ return
114
+ typer.echo(f"Values for '{meta_values}':")
115
+ for v in values:
116
+ typer.echo(f" {v['value']} [{v['layer']}] ({v['count']})")
117
+ return
118
+
119
+ # Default: list neurons
120
+ kwargs = {"limit": limit}
121
+ if type:
122
+ kwargs["type"] = type
123
+ if domain:
124
+ kwargs["domain"] = domain
125
+ neurons = await circuit.list_neurons(**kwargs)
126
+ if as_json:
127
+ _out([_neuron_dict(n, circuit) for n in neurons], use_json=True)
128
+ else:
129
+ if not neurons:
130
+ typer.echo("No neurons found.")
131
+ return
132
+ typer.echo(f"{len(neurons)} neuron(s):")
133
+ for n in neurons:
134
+ title = _extract_title(n.content)
135
+ meta = ""
136
+ if n.type:
137
+ meta += f" [{n.type}]"
138
+ if n.domain:
139
+ meta += f" @{n.domain}"
140
+ typer.echo(f" {n.id} {title}{meta}")
141
+ finally:
142
+ await circuit.close()
143
+
144
+ _run(_list())
145
+
146
+
147
+ @neuron_app.command(name="inspect")
148
+ def neuron_inspect(
149
+ neuron_id: str = typer.Argument(..., help="Neuron ID to inspect"),
150
+ as_json: bool = typer.Option(False, "--json", help="Output as JSON"),
151
+ brain: Optional[Path] = typer.Option(None, "--brain", "-b", help="Brain root directory"),
152
+ ) -> None:
153
+ """Inspect a neuron: content, FSRS state, pressure, neighbors."""
154
+
155
+ async def _inspect():
156
+ circuit = _get_circuit(brain)
157
+ await circuit.connect()
158
+ try:
159
+ neuron = await circuit.get_neuron(neuron_id)
160
+ if neuron is None:
161
+ typer.echo(f"Neuron {neuron_id} not found", err=True)
162
+ raise typer.Exit(1)
163
+
164
+ sources = await circuit.get_sources_for_neuron(neuron_id)
165
+ community_id = circuit.get_community(neuron_id)
166
+
167
+ if as_json:
168
+ d = _neuron_dict(neuron, circuit)
169
+ d["neighbors_out"] = circuit.neighbors(neuron_id)
170
+ d["neighbors_in"] = circuit.predecessors(neuron_id)
171
+ d["community_id"] = community_id
172
+ d["sources"] = [
173
+ {"id": s.id, "url": s.url, "title": s.title}
174
+ for s in sources
175
+ ]
176
+ _out(d, use_json=True)
177
+ else:
178
+ typer.echo(f"ID: {neuron.id}")
179
+ typer.echo(f"Type: {neuron.type or '-'}")
180
+ typer.echo(f"Domain: {neuron.domain or '-'}")
181
+ typer.echo(f"Created: {neuron.created_at}")
182
+
183
+ card = circuit.get_card(neuron_id)
184
+ if card:
185
+ stab = f"{card.stability:.2f}" if card.stability is not None else "-"
186
+ diff = f"{card.difficulty:.2f}" if card.difficulty is not None else "-"
187
+ typer.echo(f"FSRS: stability={stab} difficulty={diff} state={card.state.name} due={card.due}")
188
+
189
+ pressure = circuit.get_pressure(neuron_id)
190
+ typer.echo(f"Pressure: {pressure:.4f}")
191
+
192
+ if community_id is not None:
193
+ typer.echo(f"Community: {community_id}")
194
+
195
+ if sources:
196
+ typer.echo(f"Sources ({len(sources)}):")
197
+ for s in sources:
198
+ label = s.title or s.url or s.id
199
+ typer.echo(f" {s.id} {label}")
200
+
201
+ neighbors = circuit.neighbors(neuron_id)
202
+ preds = circuit.predecessors(neuron_id)
203
+ if neighbors:
204
+ typer.echo(f"Out ({len(neighbors)}): {', '.join(neighbors)}")
205
+ if preds:
206
+ typer.echo(f"In ({len(preds)}): {', '.join(preds)}")
207
+
208
+ typer.echo(f"\n{neuron.content}")
209
+ finally:
210
+ await circuit.close()
211
+
212
+ _run(_inspect())
213
+
214
+
215
+ @neuron_app.command(name="remove")
216
+ def neuron_remove(
217
+ neuron_id: str = typer.Argument(..., help="Neuron ID to remove"),
218
+ as_json: bool = typer.Option(False, "--json", help="Output as JSON"),
219
+ brain: Optional[Path] = typer.Option(None, "--brain", "-b", help="Brain root directory"),
220
+ ) -> None:
221
+ """Remove a neuron and its synapses."""
222
+
223
+ async def _remove():
224
+ circuit = _get_circuit(brain)
225
+ await circuit.connect()
226
+ try:
227
+ neuron = await circuit.get_neuron(neuron_id)
228
+ if neuron is None:
229
+ typer.echo(f"Neuron {neuron_id} not found", err=True)
230
+ raise typer.Exit(1)
231
+ await circuit.remove_neuron(neuron_id)
232
+ if as_json:
233
+ _out({"removed": neuron_id}, use_json=True)
234
+ else:
235
+ typer.echo(f"Removed neuron {neuron_id}")
236
+ finally:
237
+ await circuit.close()
238
+
239
+ _run(_remove())
240
+
241
+
242
+ @neuron_app.command(name="merge")
243
+ def neuron_merge(
244
+ source_ids: list[str] = typer.Argument(..., help="Neuron IDs to merge (absorbed)"),
245
+ into: str = typer.Option(..., "--into", help="Target neuron ID (kept)"),
246
+ as_json: bool = typer.Option(False, "--json", help="Output as JSON"),
247
+ brain: Optional[Path] = typer.Option(None, "--brain", "-b", help="Brain root directory"),
248
+ ) -> None:
249
+ """Merge multiple neurons into one target neuron.
250
+
251
+ Source neurons are absorbed: their content is appended,
252
+ synapses redirected, and source attachments transferred.
253
+ """
254
+
255
+ async def _merge():
256
+ circuit = _get_circuit(brain)
257
+ await circuit.connect()
258
+ try:
259
+ result = await circuit.merge_neurons(source_ids, into)
260
+ if as_json:
261
+ _out(result, use_json=True)
262
+ else:
263
+ typer.echo(f"Merged {result['merged']} neuron(s) into {result['into']}")
264
+ typer.echo(f" synapses redirected: {result['synapses_redirected']}")
265
+ typer.echo(f" sources transferred: {result['sources_transferred']}")
266
+ finally:
267
+ await circuit.close()
268
+
269
+ _run(_merge())
270
+
271
+
272
+ @neuron_app.command(name="due")
273
+ def neuron_due(
274
+ limit: int = typer.Option(20, "--limit", "-n", help="Max neurons to show"),
275
+ as_json: bool = typer.Option(False, "--json", help="Output as JSON"),
276
+ brain: Optional[Path] = typer.Option(None, "--brain", "-b", help="Brain root directory"),
277
+ ) -> None:
278
+ """Show neurons due for review."""
279
+
280
+ async def _due():
281
+ circuit = _get_circuit(brain)
282
+ await circuit.connect()
283
+ try:
284
+ ids = await circuit.due_neurons(limit=limit)
285
+ if as_json:
286
+ items = []
287
+ for nid in ids:
288
+ neuron = await circuit.get_neuron(nid)
289
+ if neuron:
290
+ items.append(_neuron_dict(neuron, circuit))
291
+ _out(items, use_json=True)
292
+ else:
293
+ if not ids:
294
+ typer.echo("No neurons due for review.")
295
+ return
296
+ typer.echo(f"{len(ids)} neuron(s) due:")
297
+ for nid in ids:
298
+ neuron = await circuit.get_neuron(nid)
299
+ pressure = circuit.get_pressure(nid)
300
+ title = _extract_title(neuron.content) if neuron else nid
301
+ p_indicator = f" pressure={pressure:.2f}" if pressure > 0 else ""
302
+ typer.echo(f" {nid} {title}{p_indicator}")
303
+ finally:
304
+ await circuit.close()
305
+
306
+ _run(_due())
307
+
308
+
309
+ @neuron_app.command(name="fire")
310
+ def neuron_fire(
311
+ neuron_id: str = typer.Argument(..., help="Neuron ID to fire"),
312
+ grade: str = typer.Option("fire", "--grade", "-g", help="Grade: miss|weak|fire|strong"),
313
+ as_json: bool = typer.Option(False, "--json", help="Output as JSON"),
314
+ brain: Optional[Path] = typer.Option(None, "--brain", "-b", help="Brain root directory"),
315
+ ) -> None:
316
+ """Fire a spike (record a review) on a Neuron."""
317
+ g = _GRADE_MAP.get(grade.lower())
318
+ if g is None:
319
+ typer.echo(f"Invalid grade: {grade}. Use: miss, weak, fire, strong", err=True)
320
+ raise typer.Exit(1)
321
+
322
+ async def _fire():
323
+ circuit = _get_circuit(brain)
324
+ await circuit.connect()
325
+ try:
326
+ neuron = await circuit.get_neuron(neuron_id)
327
+ if neuron is None:
328
+ typer.echo(f"Neuron {neuron_id} not found", err=True)
329
+ raise typer.Exit(1)
330
+ now = datetime.now(timezone.utc)
331
+ spike = Spike(neuron_id=neuron_id, grade=g, fired_at=now)
332
+ card = await circuit.fire(spike)
333
+ if as_json:
334
+ _out({
335
+ "neuron_id": neuron_id,
336
+ "grade": grade,
337
+ "stability": card.stability,
338
+ "difficulty": card.difficulty,
339
+ "due": str(card.due),
340
+ "state": card.state.name,
341
+ }, use_json=True)
342
+ else:
343
+ typer.echo(f"Fired {grade} on {neuron_id}")
344
+ typer.echo(f" stability={card.stability:.2f} difficulty={card.difficulty:.2f} due={card.due}")
345
+ finally:
346
+ await circuit.close()
347
+
348
+ _run(_fire())