kiwimatecoder 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.
- kiwimatecoder/__init__.py +8 -0
- kiwimatecoder/__main__.py +12 -0
- kiwimatecoder/acp/__init__.py +13 -0
- kiwimatecoder/acp/protocol.py +115 -0
- kiwimatecoder/acp/server.py +823 -0
- kiwimatecoder/acp/stdio.py +32 -0
- kiwimatecoder/agent.py +1162 -0
- kiwimatecoder/ai.py +68 -0
- kiwimatecoder/audit.py +65 -0
- kiwimatecoder/browser.py +261 -0
- kiwimatecoder/catalog.py +445 -0
- kiwimatecoder/checkpoints.py +103 -0
- kiwimatecoder/client.py +820 -0
- kiwimatecoder/clipboard.py +99 -0
- kiwimatecoder/commands.py +6757 -0
- kiwimatecoder/config.py +5335 -0
- kiwimatecoder/diagnostics.py +295 -0
- kiwimatecoder/documents.py +299 -0
- kiwimatecoder/evals/__init__.py +39 -0
- kiwimatecoder/evals/cases.py +230 -0
- kiwimatecoder/evals/render.py +46 -0
- kiwimatecoder/evals/runner.py +319 -0
- kiwimatecoder/events.py +95 -0
- kiwimatecoder/forge.py +172 -0
- kiwimatecoder/git.py +164 -0
- kiwimatecoder/headless.py +255 -0
- kiwimatecoder/hooks.py +170 -0
- kiwimatecoder/hunks.py +260 -0
- kiwimatecoder/i18n.py +368 -0
- kiwimatecoder/images.py +195 -0
- kiwimatecoder/index/__init__.py +14 -0
- kiwimatecoder/index/builder.py +376 -0
- kiwimatecoder/index/embeddings.py +151 -0
- kiwimatecoder/index/search.py +247 -0
- kiwimatecoder/index/store.py +251 -0
- kiwimatecoder/instructions.py +83 -0
- kiwimatecoder/jobs.py +594 -0
- kiwimatecoder/lsp/__init__.py +56 -0
- kiwimatecoder/lsp/client.py +548 -0
- kiwimatecoder/lsp/manager.py +353 -0
- kiwimatecoder/lsp/protocol.py +79 -0
- kiwimatecoder/main.py +4258 -0
- kiwimatecoder/mcp/__init__.py +45 -0
- kiwimatecoder/mcp/client.py +385 -0
- kiwimatecoder/mcp/errors.py +7 -0
- kiwimatecoder/mcp/manager.py +253 -0
- kiwimatecoder/mcp/protocol.py +132 -0
- kiwimatecoder/media.py +904 -0
- kiwimatecoder/memory.py +141 -0
- kiwimatecoder/network.py +112 -0
- kiwimatecoder/notify.py +139 -0
- kiwimatecoder/permissions.py +171 -0
- kiwimatecoder/pickers.py +417 -0
- kiwimatecoder/plugins.py +245 -0
- kiwimatecoder/pricing.py +167 -0
- kiwimatecoder/prompts.py +213 -0
- kiwimatecoder/providers.py +342 -0
- kiwimatecoder/redaction.py +66 -0
- kiwimatecoder/remote.py +294 -0
- kiwimatecoder/repl.py +1388 -0
- kiwimatecoder/routing.py +65 -0
- kiwimatecoder/sandbox.py +169 -0
- kiwimatecoder/sdk.py +146 -0
- kiwimatecoder/session.py +574 -0
- kiwimatecoder/share.py +386 -0
- kiwimatecoder/shell.py +660 -0
- kiwimatecoder/skills.py +138 -0
- kiwimatecoder/sync.py +513 -0
- kiwimatecoder/team.py +249 -0
- kiwimatecoder/telemetry.py +392 -0
- kiwimatecoder/templates.py +108 -0
- kiwimatecoder/tools/__init__.py +190 -0
- kiwimatecoder/tools/ask.py +62 -0
- kiwimatecoder/tools/base.py +69 -0
- kiwimatecoder/tools/browser.py +261 -0
- kiwimatecoder/tools/edit_file.py +143 -0
- kiwimatecoder/tools/forge.py +207 -0
- kiwimatecoder/tools/git.py +158 -0
- kiwimatecoder/tools/image.py +300 -0
- kiwimatecoder/tools/io.py +346 -0
- kiwimatecoder/tools/list_dir.py +67 -0
- kiwimatecoder/tools/lsp.py +254 -0
- kiwimatecoder/tools/memory.py +115 -0
- kiwimatecoder/tools/paths.py +234 -0
- kiwimatecoder/tools/read_file.py +113 -0
- kiwimatecoder/tools/registry.py +91 -0
- kiwimatecoder/tools/run_bash.py +189 -0
- kiwimatecoder/tools/search.py +224 -0
- kiwimatecoder/tools/selection.py +108 -0
- kiwimatecoder/tools/shell.py +158 -0
- kiwimatecoder/tools/skill.py +46 -0
- kiwimatecoder/tools/task.py +61 -0
- kiwimatecoder/tools/todo.py +77 -0
- kiwimatecoder/tools/web.py +146 -0
- kiwimatecoder/tools/write_file.py +83 -0
- kiwimatecoder/ui.py +189 -0
- kiwimatecoder/updater.py +502 -0
- kiwimatecoder/web.py +499 -0
- kiwimatecoder-0.1.0.dist-info/METADATA +2016 -0
- kiwimatecoder-0.1.0.dist-info/RECORD +104 -0
- kiwimatecoder-0.1.0.dist-info/WHEEL +5 -0
- kiwimatecoder-0.1.0.dist-info/entry_points.txt +2 -0
- kiwimatecoder-0.1.0.dist-info/licenses/LICENSE +21 -0
- kiwimatecoder-0.1.0.dist-info/top_level.txt +1 -0
kiwimatecoder/share.py
ADDED
|
@@ -0,0 +1,386 @@
|
|
|
1
|
+
"""Redacted, shareable session bundles (local files, or a secret GitHub gist).
|
|
2
|
+
|
|
3
|
+
Sharing is deliberately local-first: a bundle is a redacted JSON snapshot of a
|
|
4
|
+
conversation that can be committed, pasted into a chat, or uploaded to a GitHub
|
|
5
|
+
gist through the user's own ``gh`` login. There is no hosted service. Tool
|
|
6
|
+
output is excluded by default and every string passes through
|
|
7
|
+
:func:`kiwimatecoder.redaction.redact`, so API keys never leave the machine.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import datetime
|
|
13
|
+
import json
|
|
14
|
+
import os
|
|
15
|
+
import re
|
|
16
|
+
import subprocess
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
from kiwimatecoder import forge
|
|
21
|
+
from kiwimatecoder.redaction import redact
|
|
22
|
+
from kiwimatecoder.session import Session, save_session
|
|
23
|
+
|
|
24
|
+
SHARE_VERSION = 1
|
|
25
|
+
MAX_SHARE_BYTES = 256 * 1024
|
|
26
|
+
MAX_MESSAGE_CHARS = 16_000
|
|
27
|
+
MAX_TOOL_CHARS = 2048
|
|
28
|
+
OMITTED_TOOL_OUTPUT = "[tool output omitted]"
|
|
29
|
+
OMITTED_IMAGE = "[image omitted]"
|
|
30
|
+
SHARES_DIR_NAME = "shares"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class ShareError(Exception):
|
|
34
|
+
"""Raised when a share bundle cannot be read, validated, or imported."""
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _truncate(text: str, limit: int) -> str:
|
|
38
|
+
"""Clip ``text`` to ``limit`` chars, noting how much was dropped."""
|
|
39
|
+
if len(text) <= limit:
|
|
40
|
+
return text
|
|
41
|
+
return text[:limit] + f"\n[truncated {len(text) - limit} chars]"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _share_text(value: Any, limit: int = MAX_MESSAGE_CHARS) -> str:
|
|
45
|
+
"""Redact and clip one text value for a share bundle."""
|
|
46
|
+
return _truncate(redact(str(value)), limit)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _share_arguments(arguments: Any) -> str:
|
|
50
|
+
"""Keep a tool call's arguments, redacted and clipped (names stay visible)."""
|
|
51
|
+
if arguments is None:
|
|
52
|
+
return "{}"
|
|
53
|
+
if isinstance(arguments, str):
|
|
54
|
+
raw = arguments
|
|
55
|
+
else:
|
|
56
|
+
raw = json.dumps(arguments, default=str, sort_keys=True)
|
|
57
|
+
return _truncate(redact(raw), MAX_TOOL_CHARS)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _share_block(block: Any, include_tool_output: bool) -> dict[str, Any]:
|
|
61
|
+
"""Convert one content block, dropping images and (by default) tool output."""
|
|
62
|
+
if isinstance(block, str):
|
|
63
|
+
return {"type": "text", "text": _share_text(block)}
|
|
64
|
+
if not isinstance(block, dict):
|
|
65
|
+
return {"type": "text", "text": _share_text(block)}
|
|
66
|
+
block_type = str(block.get("type") or "text")
|
|
67
|
+
if block_type == "tool_result":
|
|
68
|
+
content = block.get("content")
|
|
69
|
+
if isinstance(content, str):
|
|
70
|
+
text = content
|
|
71
|
+
else:
|
|
72
|
+
text = "" if content is None else json.dumps(content, default=str)
|
|
73
|
+
return {
|
|
74
|
+
"type": "tool_result",
|
|
75
|
+
"tool_use_id": str(block.get("tool_use_id") or ""),
|
|
76
|
+
"content": (
|
|
77
|
+
_truncate(redact(text), MAX_TOOL_CHARS)
|
|
78
|
+
if include_tool_output
|
|
79
|
+
else OMITTED_TOOL_OUTPUT
|
|
80
|
+
),
|
|
81
|
+
}
|
|
82
|
+
if block_type in {"image", "image_url", "input_image"}:
|
|
83
|
+
return {"type": "text", "text": OMITTED_IMAGE}
|
|
84
|
+
if block_type == "text":
|
|
85
|
+
return {"type": "text", "text": _share_text(block.get("text"))}
|
|
86
|
+
return {"type": "text", "text": _share_text(json.dumps(block, default=str))}
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _share_message(
|
|
90
|
+
message: dict[str, Any], include_tool_output: bool
|
|
91
|
+
) -> dict[str, Any]:
|
|
92
|
+
"""Build one redacted message, preserving roles and tool call names."""
|
|
93
|
+
role = str(message.get("role") or "unknown")
|
|
94
|
+
shared: dict[str, Any] = {"role": role}
|
|
95
|
+
content = message.get("content")
|
|
96
|
+
if isinstance(content, list):
|
|
97
|
+
shared["content"] = [
|
|
98
|
+
_share_block(block, include_tool_output) for block in content
|
|
99
|
+
]
|
|
100
|
+
elif role == "tool":
|
|
101
|
+
shared["content"] = (
|
|
102
|
+
_share_text("" if content is None else content, MAX_TOOL_CHARS)
|
|
103
|
+
if include_tool_output
|
|
104
|
+
else OMITTED_TOOL_OUTPUT
|
|
105
|
+
)
|
|
106
|
+
else:
|
|
107
|
+
shared["content"] = _share_text("" if content is None else content)
|
|
108
|
+
if message.get("tool_call_id"):
|
|
109
|
+
shared["tool_call_id"] = str(message["tool_call_id"])
|
|
110
|
+
tool_calls = message.get("tool_calls")
|
|
111
|
+
if isinstance(tool_calls, list):
|
|
112
|
+
kept: list[dict[str, Any]] = []
|
|
113
|
+
for call in tool_calls:
|
|
114
|
+
if not isinstance(call, dict):
|
|
115
|
+
continue
|
|
116
|
+
function = call.get("function")
|
|
117
|
+
if not isinstance(function, dict):
|
|
118
|
+
function = {}
|
|
119
|
+
kept.append(
|
|
120
|
+
{
|
|
121
|
+
"id": str(call.get("id") or ""),
|
|
122
|
+
"type": str(call.get("type") or "function"),
|
|
123
|
+
"function": {
|
|
124
|
+
"name": str(function.get("name") or ""),
|
|
125
|
+
"arguments": _share_arguments(function.get("arguments")),
|
|
126
|
+
},
|
|
127
|
+
}
|
|
128
|
+
)
|
|
129
|
+
if kept:
|
|
130
|
+
shared["tool_calls"] = kept
|
|
131
|
+
return shared
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _content_text(message: dict[str, Any]) -> str:
|
|
135
|
+
"""Extract plain text from one message's content (for summary/slug)."""
|
|
136
|
+
content = message.get("content")
|
|
137
|
+
if isinstance(content, list):
|
|
138
|
+
return "\n".join(
|
|
139
|
+
str(block.get("text") or "")
|
|
140
|
+
for block in content
|
|
141
|
+
if isinstance(block, dict) and block.get("type") == "text"
|
|
142
|
+
)
|
|
143
|
+
return "" if content is None else str(content)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _summary(messages: list[dict[str, Any]]) -> str:
|
|
147
|
+
"""The first non-empty user message, redacted and clipped for display."""
|
|
148
|
+
for message in messages:
|
|
149
|
+
if str(message.get("role") or "") != "user":
|
|
150
|
+
continue
|
|
151
|
+
cleaned = redact(" ".join(_content_text(message).split()))
|
|
152
|
+
if cleaned:
|
|
153
|
+
return _truncate(cleaned, 200)
|
|
154
|
+
return ""
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _slug(session: Session) -> str:
|
|
158
|
+
"""A filename-safe slug derived from the first user message."""
|
|
159
|
+
for message in session.messages:
|
|
160
|
+
if not isinstance(message, dict) or message.get("role") != "user":
|
|
161
|
+
continue
|
|
162
|
+
words = re.findall(r"[a-z0-9]+", redact(_content_text(message)).lower())
|
|
163
|
+
if words:
|
|
164
|
+
return "-".join(words[:6])[:48].strip("-") or "session"
|
|
165
|
+
return "session"
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _encoded_size(bundle: dict[str, Any]) -> int:
|
|
169
|
+
return len(json.dumps(bundle, ensure_ascii=False, default=str).encode("utf-8"))
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _fit_bundle(bundle: dict[str, Any]) -> None:
|
|
173
|
+
"""Drop oldest messages until the serialized bundle fits the size cap."""
|
|
174
|
+
messages: list[dict[str, Any]] = bundle["messages"]
|
|
175
|
+
if _encoded_size(bundle) <= MAX_SHARE_BYTES:
|
|
176
|
+
return
|
|
177
|
+
dropped = 0
|
|
178
|
+
# Set the real markers before measuring so the final size is within cap.
|
|
179
|
+
bundle["truncated"] = True
|
|
180
|
+
bundle["truncated_messages"] = 0
|
|
181
|
+
while messages and _encoded_size(bundle) > MAX_SHARE_BYTES:
|
|
182
|
+
messages.pop(0)
|
|
183
|
+
dropped += 1
|
|
184
|
+
bundle["truncated_messages"] = dropped
|
|
185
|
+
# Never start with an orphaned tool result (some providers reject it).
|
|
186
|
+
while messages and messages[0].get("role") == "tool":
|
|
187
|
+
messages.pop(0)
|
|
188
|
+
dropped += 1
|
|
189
|
+
bundle["truncated_messages"] = dropped
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def build_share(session: Session, *, include_tool_output: bool = False) -> dict[str, Any]:
|
|
193
|
+
"""Build a redacted share bundle for ``session``.
|
|
194
|
+
|
|
195
|
+
Tool results are replaced with a placeholder unless ``include_tool_output``
|
|
196
|
+
is set, in which case each result is redacted and clipped to 2 KB; tool
|
|
197
|
+
call arguments are always redacted and clipped. The whole bundle is capped
|
|
198
|
+
at ~256 KB: when the conversation is larger the oldest messages are dropped
|
|
199
|
+
and ``truncated``/``truncated_messages`` record it.
|
|
200
|
+
"""
|
|
201
|
+
messages = [
|
|
202
|
+
_share_message(message, include_tool_output)
|
|
203
|
+
for message in session.messages
|
|
204
|
+
if isinstance(message, dict)
|
|
205
|
+
]
|
|
206
|
+
bundle: dict[str, Any] = {
|
|
207
|
+
"version": SHARE_VERSION,
|
|
208
|
+
"created_at": datetime.datetime.now().isoformat(),
|
|
209
|
+
"provider": session.provider_id,
|
|
210
|
+
"model": session.model,
|
|
211
|
+
"mode": session.mode.value,
|
|
212
|
+
"summary": _summary(messages),
|
|
213
|
+
"messages": messages,
|
|
214
|
+
"todos": [dict(todo) for todo in session.todos if isinstance(todo, dict)],
|
|
215
|
+
"stats": {
|
|
216
|
+
"messages": len(messages),
|
|
217
|
+
"prompt_tokens": session.prompt_tokens,
|
|
218
|
+
"completion_tokens": session.completion_tokens,
|
|
219
|
+
"total_tokens": session.total_tokens,
|
|
220
|
+
},
|
|
221
|
+
"truncated": False,
|
|
222
|
+
"truncated_messages": 0,
|
|
223
|
+
}
|
|
224
|
+
_fit_bundle(bundle)
|
|
225
|
+
return bundle
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def default_share_path(session: Session) -> Path:
|
|
229
|
+
"""Default bundle path: ``<workspace>/.kiwimatecoder/shares/<ts>-<slug>.json``."""
|
|
230
|
+
stamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
231
|
+
return (
|
|
232
|
+
session.workspace_root
|
|
233
|
+
/ ".kiwimatecoder"
|
|
234
|
+
/ SHARES_DIR_NAME
|
|
235
|
+
/ f"{stamp}-{_slug(session)}.share.json"
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def write_share(
|
|
240
|
+
session: Session,
|
|
241
|
+
dest: str | Path | None = None,
|
|
242
|
+
include_tool_output: bool = False,
|
|
243
|
+
) -> Path:
|
|
244
|
+
"""Write a redacted bundle atomically and return the file path.
|
|
245
|
+
|
|
246
|
+
``dest`` may be a directory (the default share name is used inside it) or a
|
|
247
|
+
file path; when omitted the bundle lands in the session workspace under
|
|
248
|
+
``.kiwimatecoder/shares/``.
|
|
249
|
+
"""
|
|
250
|
+
bundle = build_share(session, include_tool_output=include_tool_output)
|
|
251
|
+
if dest is None:
|
|
252
|
+
target = default_share_path(session)
|
|
253
|
+
else:
|
|
254
|
+
target = Path(dest).expanduser()
|
|
255
|
+
if target.is_dir():
|
|
256
|
+
target = target / default_share_path(session).name
|
|
257
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
258
|
+
payload = json.dumps(bundle, indent=2, ensure_ascii=False) + "\n"
|
|
259
|
+
temp = target.with_name(f".{target.name}.tmp")
|
|
260
|
+
temp.write_text(payload, encoding="utf-8")
|
|
261
|
+
os.replace(temp, target)
|
|
262
|
+
return target
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def load_share(path: str | Path) -> dict[str, Any]:
|
|
266
|
+
"""Load and validate a share bundle, tolerating missing optional fields.
|
|
267
|
+
|
|
268
|
+
Raises :class:`ShareError` for a missing file, unreadable data, a document
|
|
269
|
+
that is not an object, an unsupported version, or a missing message list.
|
|
270
|
+
"""
|
|
271
|
+
target = Path(path).expanduser()
|
|
272
|
+
if not target.is_file():
|
|
273
|
+
raise ShareError(f"Share file not found: {path}")
|
|
274
|
+
try:
|
|
275
|
+
data = json.loads(target.read_text(encoding="utf-8"))
|
|
276
|
+
except (json.JSONDecodeError, OSError) as exc:
|
|
277
|
+
raise ShareError(f"Cannot read share file {path}: {exc}") from exc
|
|
278
|
+
if not isinstance(data, dict):
|
|
279
|
+
raise ShareError(f"Share file {path} must be a JSON object.")
|
|
280
|
+
version = data.get("version")
|
|
281
|
+
if version != SHARE_VERSION:
|
|
282
|
+
raise ShareError(
|
|
283
|
+
f"Unsupported share version {version!r} (expected {SHARE_VERSION})."
|
|
284
|
+
)
|
|
285
|
+
messages = data.get("messages")
|
|
286
|
+
if not isinstance(messages, list):
|
|
287
|
+
raise ShareError(f"Share file {path} has no message list.")
|
|
288
|
+
stats = data.get("stats")
|
|
289
|
+
try:
|
|
290
|
+
truncated_messages = int(data.get("truncated_messages") or 0)
|
|
291
|
+
except (TypeError, ValueError):
|
|
292
|
+
truncated_messages = 0
|
|
293
|
+
return {
|
|
294
|
+
"version": SHARE_VERSION,
|
|
295
|
+
"created_at": str(data.get("created_at") or ""),
|
|
296
|
+
"provider": str(data.get("provider") or ""),
|
|
297
|
+
"model": str(data.get("model") or ""),
|
|
298
|
+
"mode": str(data.get("mode") or "ask"),
|
|
299
|
+
"summary": str(data.get("summary") or ""),
|
|
300
|
+
"messages": [item for item in messages if isinstance(item, dict)],
|
|
301
|
+
"todos": [item for item in (data.get("todos") or []) if isinstance(item, dict)],
|
|
302
|
+
"stats": stats if isinstance(stats, dict) else {},
|
|
303
|
+
"truncated": bool(data.get("truncated", False)),
|
|
304
|
+
"truncated_messages": truncated_messages,
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def import_share(path: str | Path, name: str | None = None) -> Path:
|
|
309
|
+
"""Create a normal saved session from a share bundle and return its path.
|
|
310
|
+
|
|
311
|
+
The reconstructed session can be resumed with ``/load`` or
|
|
312
|
+
``--resume``; a bundle without a provider falls back to the configured
|
|
313
|
+
primary provider so it stays loadable.
|
|
314
|
+
"""
|
|
315
|
+
bundle = load_share(path)
|
|
316
|
+
provider_id = bundle["provider"]
|
|
317
|
+
if not provider_id:
|
|
318
|
+
from kiwimatecoder.config import get_selected_provider_id
|
|
319
|
+
|
|
320
|
+
provider_id = get_selected_provider_id()
|
|
321
|
+
session = Session.from_dict(
|
|
322
|
+
{
|
|
323
|
+
"provider_id": provider_id,
|
|
324
|
+
"model": bundle["model"],
|
|
325
|
+
"mode": bundle["mode"],
|
|
326
|
+
"messages": bundle["messages"],
|
|
327
|
+
"todos": bundle["todos"],
|
|
328
|
+
"active_provider_ids": [provider_id],
|
|
329
|
+
}
|
|
330
|
+
)
|
|
331
|
+
if not name:
|
|
332
|
+
stamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
333
|
+
name = f"shared_{stamp}"
|
|
334
|
+
return save_session(session, name)
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def gist_args(path: str | Path) -> list[str]:
|
|
338
|
+
"""Return the exact ``gh gist create`` argv for a share file (secret gist)."""
|
|
339
|
+
return [
|
|
340
|
+
"gh",
|
|
341
|
+
"gist",
|
|
342
|
+
"create",
|
|
343
|
+
"--public=false",
|
|
344
|
+
"--desc",
|
|
345
|
+
"kiwimatecoder session share",
|
|
346
|
+
str(path),
|
|
347
|
+
]
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def share_to_gist(
|
|
351
|
+
session: Session,
|
|
352
|
+
*,
|
|
353
|
+
include_tool_output: bool = False,
|
|
354
|
+
dest: str | Path | None = None,
|
|
355
|
+
) -> tuple[bool, str]:
|
|
356
|
+
"""Write a bundle and upload it as a secret gist via the user's ``gh`` CLI.
|
|
357
|
+
|
|
358
|
+
Returns ``(ok, url-or-error)``; failures are messages, never exceptions, so
|
|
359
|
+
callers can print them. ``gh`` must be installed and authenticated; no
|
|
360
|
+
token is read or stored by KiwiMateCoder.
|
|
361
|
+
"""
|
|
362
|
+
if not forge.cli_available("gh"):
|
|
363
|
+
return (
|
|
364
|
+
False,
|
|
365
|
+
"gh is not installed or not on PATH. Install the GitHub CLI and "
|
|
366
|
+
"run `gh auth login` first.",
|
|
367
|
+
)
|
|
368
|
+
path = write_share(session, dest=dest, include_tool_output=include_tool_output)
|
|
369
|
+
try:
|
|
370
|
+
proc = subprocess.run(
|
|
371
|
+
gist_args(path),
|
|
372
|
+
capture_output=True,
|
|
373
|
+
text=True,
|
|
374
|
+
check=False,
|
|
375
|
+
timeout=60,
|
|
376
|
+
)
|
|
377
|
+
except FileNotFoundError:
|
|
378
|
+
return False, "gh is not installed or not on PATH."
|
|
379
|
+
except subprocess.TimeoutExpired:
|
|
380
|
+
return False, "gh gist create timed out after 60s."
|
|
381
|
+
output = (proc.stdout or "").strip()
|
|
382
|
+
if proc.returncode != 0:
|
|
383
|
+
detail = (proc.stderr or output or "gh gist create failed").strip()
|
|
384
|
+
return False, detail
|
|
385
|
+
url = output.splitlines()[0].strip() if output else ""
|
|
386
|
+
return True, url or "Gist created."
|