bhu-cli 1.46.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.
- bhu_cli-1.46.0.dist-info/METADATA +210 -0
- bhu_cli-1.46.0.dist-info/RECORD +258 -0
- bhu_cli-1.46.0.dist-info/WHEEL +4 -0
- bhu_cli-1.46.0.dist-info/entry_points.txt +4 -0
- kimi_cli/CHANGELOG.md +1281 -0
- kimi_cli/__init__.py +29 -0
- kimi_cli/__main__.py +43 -0
- kimi_cli/_build_info.py +1 -0
- kimi_cli/acp/AGENTS.md +92 -0
- kimi_cli/acp/__init__.py +13 -0
- kimi_cli/acp/convert.py +128 -0
- kimi_cli/acp/kaos.py +291 -0
- kimi_cli/acp/mcp.py +46 -0
- kimi_cli/acp/server.py +468 -0
- kimi_cli/acp/session.py +499 -0
- kimi_cli/acp/tools.py +167 -0
- kimi_cli/acp/types.py +13 -0
- kimi_cli/acp/version.py +45 -0
- kimi_cli/agents/default/agent.yaml +36 -0
- kimi_cli/agents/default/coder.yaml +25 -0
- kimi_cli/agents/default/explore.yaml +46 -0
- kimi_cli/agents/default/plan.yaml +30 -0
- kimi_cli/agents/default/system.md +160 -0
- kimi_cli/agents/okabe/agent.yaml +22 -0
- kimi_cli/agentspec.py +160 -0
- kimi_cli/app.py +807 -0
- kimi_cli/approval_runtime/__init__.py +29 -0
- kimi_cli/approval_runtime/models.py +42 -0
- kimi_cli/approval_runtime/runtime.py +235 -0
- kimi_cli/auth/__init__.py +5 -0
- kimi_cli/auth/oauth.py +1092 -0
- kimi_cli/auth/platforms.py +374 -0
- kimi_cli/background/__init__.py +36 -0
- kimi_cli/background/agent_runner.py +231 -0
- kimi_cli/background/ids.py +19 -0
- kimi_cli/background/manager.py +725 -0
- kimi_cli/background/models.py +105 -0
- kimi_cli/background/store.py +237 -0
- kimi_cli/background/summary.py +66 -0
- kimi_cli/background/worker.py +209 -0
- kimi_cli/cli/__init__.py +1081 -0
- kimi_cli/cli/__main__.py +34 -0
- kimi_cli/cli/_lazy_group.py +238 -0
- kimi_cli/cli/export.py +322 -0
- kimi_cli/cli/info.py +62 -0
- kimi_cli/cli/mcp.py +353 -0
- kimi_cli/cli/plugin.py +347 -0
- kimi_cli/cli/toad.py +73 -0
- kimi_cli/cli/vis.py +38 -0
- kimi_cli/cli/web.py +80 -0
- kimi_cli/config.py +429 -0
- kimi_cli/constant.py +116 -0
- kimi_cli/exception.py +43 -0
- kimi_cli/hooks/__init__.py +24 -0
- kimi_cli/hooks/config.py +34 -0
- kimi_cli/hooks/defaults/rtk.py +48 -0
- kimi_cli/hooks/engine.py +394 -0
- kimi_cli/hooks/events.py +190 -0
- kimi_cli/hooks/runner.py +103 -0
- kimi_cli/llm.py +333 -0
- kimi_cli/mcp_oauth.py +72 -0
- kimi_cli/metadata.py +79 -0
- kimi_cli/notifications/__init__.py +33 -0
- kimi_cli/notifications/llm.py +77 -0
- kimi_cli/notifications/manager.py +145 -0
- kimi_cli/notifications/models.py +50 -0
- kimi_cli/notifications/notifier.py +41 -0
- kimi_cli/notifications/store.py +118 -0
- kimi_cli/notifications/wire.py +21 -0
- kimi_cli/plugin/__init__.py +124 -0
- kimi_cli/plugin/manager.py +153 -0
- kimi_cli/plugin/tool.py +173 -0
- kimi_cli/prompts/__init__.py +6 -0
- kimi_cli/prompts/compact.md +73 -0
- kimi_cli/prompts/init.md +21 -0
- kimi_cli/py.typed +0 -0
- kimi_cli/session.py +319 -0
- kimi_cli/session_fork.py +325 -0
- kimi_cli/session_state.py +132 -0
- kimi_cli/share.py +14 -0
- kimi_cli/skill/__init__.py +727 -0
- kimi_cli/skill/flow/__init__.py +99 -0
- kimi_cli/skill/flow/d2.py +482 -0
- kimi_cli/skill/flow/mermaid.py +266 -0
- kimi_cli/skills/kimi-cli-help/SKILL.md +55 -0
- kimi_cli/skills/skill-creator/SKILL.md +367 -0
- kimi_cli/soul/__init__.py +304 -0
- kimi_cli/soul/agent.py +519 -0
- kimi_cli/soul/approval.py +267 -0
- kimi_cli/soul/btw.py +237 -0
- kimi_cli/soul/compaction.py +189 -0
- kimi_cli/soul/context.py +339 -0
- kimi_cli/soul/denwarenji.py +39 -0
- kimi_cli/soul/dynamic_injection.py +84 -0
- kimi_cli/soul/dynamic_injections/__init__.py +0 -0
- kimi_cli/soul/dynamic_injections/afk_mode.py +74 -0
- kimi_cli/soul/dynamic_injections/plan_mode.py +245 -0
- kimi_cli/soul/kimisoul.py +1710 -0
- kimi_cli/soul/message.py +92 -0
- kimi_cli/soul/slash.py +341 -0
- kimi_cli/soul/toolset.py +891 -0
- kimi_cli/subagents/__init__.py +21 -0
- kimi_cli/subagents/builder.py +42 -0
- kimi_cli/subagents/core.py +86 -0
- kimi_cli/subagents/git_context.py +170 -0
- kimi_cli/subagents/models.py +54 -0
- kimi_cli/subagents/output.py +71 -0
- kimi_cli/subagents/registry.py +28 -0
- kimi_cli/subagents/runner.py +428 -0
- kimi_cli/subagents/store.py +196 -0
- kimi_cli/telemetry/__init__.py +197 -0
- kimi_cli/telemetry/crash.py +149 -0
- kimi_cli/telemetry/sink.py +143 -0
- kimi_cli/telemetry/transport.py +318 -0
- kimi_cli/tools/AGENTS.md +5 -0
- kimi_cli/tools/__init__.py +105 -0
- kimi_cli/tools/agent/__init__.py +277 -0
- kimi_cli/tools/agent/description.md +41 -0
- kimi_cli/tools/ask_user/__init__.py +154 -0
- kimi_cli/tools/ask_user/description.md +19 -0
- kimi_cli/tools/background/__init__.py +318 -0
- kimi_cli/tools/background/list.md +10 -0
- kimi_cli/tools/background/output.md +11 -0
- kimi_cli/tools/background/stop.md +8 -0
- kimi_cli/tools/display.py +46 -0
- kimi_cli/tools/dmail/__init__.py +38 -0
- kimi_cli/tools/dmail/dmail.md +17 -0
- kimi_cli/tools/file/__init__.py +30 -0
- kimi_cli/tools/file/glob.md +21 -0
- kimi_cli/tools/file/glob.py +178 -0
- kimi_cli/tools/file/grep.md +6 -0
- kimi_cli/tools/file/grep_local.py +590 -0
- kimi_cli/tools/file/plan_mode.py +45 -0
- kimi_cli/tools/file/read.md +16 -0
- kimi_cli/tools/file/read.py +300 -0
- kimi_cli/tools/file/read_media.md +24 -0
- kimi_cli/tools/file/read_media.py +217 -0
- kimi_cli/tools/file/replace.md +7 -0
- kimi_cli/tools/file/replace.py +195 -0
- kimi_cli/tools/file/utils.py +257 -0
- kimi_cli/tools/file/write.md +5 -0
- kimi_cli/tools/file/write.py +177 -0
- kimi_cli/tools/plan/__init__.py +339 -0
- kimi_cli/tools/plan/description.md +29 -0
- kimi_cli/tools/plan/enter.py +197 -0
- kimi_cli/tools/plan/enter_description.md +35 -0
- kimi_cli/tools/plan/heroes.py +277 -0
- kimi_cli/tools/shell/__init__.py +260 -0
- kimi_cli/tools/shell/bash.md +35 -0
- kimi_cli/tools/test.py +55 -0
- kimi_cli/tools/think/__init__.py +21 -0
- kimi_cli/tools/think/think.md +1 -0
- kimi_cli/tools/todo/__init__.py +168 -0
- kimi_cli/tools/todo/set_todo_list.md +23 -0
- kimi_cli/tools/utils.py +199 -0
- kimi_cli/tools/web/__init__.py +4 -0
- kimi_cli/tools/web/fetch.md +1 -0
- kimi_cli/tools/web/fetch.py +189 -0
- kimi_cli/tools/web/search.md +1 -0
- kimi_cli/tools/web/search.py +163 -0
- kimi_cli/ui/__init__.py +0 -0
- kimi_cli/ui/acp/__init__.py +99 -0
- kimi_cli/ui/print/__init__.py +474 -0
- kimi_cli/ui/print/visualize.py +194 -0
- kimi_cli/ui/shell/__init__.py +1540 -0
- kimi_cli/ui/shell/console.py +109 -0
- kimi_cli/ui/shell/debug.py +190 -0
- kimi_cli/ui/shell/echo.py +17 -0
- kimi_cli/ui/shell/export_import.py +117 -0
- kimi_cli/ui/shell/keyboard.py +300 -0
- kimi_cli/ui/shell/mcp_status.py +111 -0
- kimi_cli/ui/shell/oauth.py +149 -0
- kimi_cli/ui/shell/placeholders.py +531 -0
- kimi_cli/ui/shell/prompt.py +2259 -0
- kimi_cli/ui/shell/replay.py +215 -0
- kimi_cli/ui/shell/session_picker.py +227 -0
- kimi_cli/ui/shell/setup.py +212 -0
- kimi_cli/ui/shell/slash.py +893 -0
- kimi_cli/ui/shell/startup.py +32 -0
- kimi_cli/ui/shell/task_browser.py +486 -0
- kimi_cli/ui/shell/update.py +349 -0
- kimi_cli/ui/shell/usage.py +295 -0
- kimi_cli/ui/shell/visualize/__init__.py +165 -0
- kimi_cli/ui/shell/visualize/_approval_panel.py +505 -0
- kimi_cli/ui/shell/visualize/_blocks.py +640 -0
- kimi_cli/ui/shell/visualize/_btw_panel.py +224 -0
- kimi_cli/ui/shell/visualize/_input_router.py +48 -0
- kimi_cli/ui/shell/visualize/_interactive.py +524 -0
- kimi_cli/ui/shell/visualize/_live_view.py +902 -0
- kimi_cli/ui/shell/visualize/_question_panel.py +586 -0
- kimi_cli/ui/theme.py +241 -0
- kimi_cli/utils/__init__.py +0 -0
- kimi_cli/utils/aiohttp.py +24 -0
- kimi_cli/utils/aioqueue.py +72 -0
- kimi_cli/utils/broadcast.py +37 -0
- kimi_cli/utils/changelog.py +108 -0
- kimi_cli/utils/clipboard.py +246 -0
- kimi_cli/utils/datetime.py +64 -0
- kimi_cli/utils/diff.py +135 -0
- kimi_cli/utils/editor.py +91 -0
- kimi_cli/utils/environment.py +225 -0
- kimi_cli/utils/envvar.py +22 -0
- kimi_cli/utils/export.py +696 -0
- kimi_cli/utils/file_filter.py +367 -0
- kimi_cli/utils/frontmatter.py +70 -0
- kimi_cli/utils/io.py +27 -0
- kimi_cli/utils/logging.py +124 -0
- kimi_cli/utils/media_tags.py +29 -0
- kimi_cli/utils/message.py +24 -0
- kimi_cli/utils/path.py +245 -0
- kimi_cli/utils/proctitle.py +33 -0
- kimi_cli/utils/proxy.py +31 -0
- kimi_cli/utils/pyinstaller.py +43 -0
- kimi_cli/utils/rich/__init__.py +33 -0
- kimi_cli/utils/rich/columns.py +99 -0
- kimi_cli/utils/rich/diff_render.py +481 -0
- kimi_cli/utils/rich/markdown.py +898 -0
- kimi_cli/utils/rich/markdown_sample.md +108 -0
- kimi_cli/utils/rich/markdown_sample_short.md +2 -0
- kimi_cli/utils/rich/syntax.py +114 -0
- kimi_cli/utils/sensitive.py +54 -0
- kimi_cli/utils/server.py +121 -0
- kimi_cli/utils/shell_quoting.py +38 -0
- kimi_cli/utils/signals.py +43 -0
- kimi_cli/utils/slashcmd.py +145 -0
- kimi_cli/utils/string.py +41 -0
- kimi_cli/utils/subprocess_env.py +73 -0
- kimi_cli/utils/term.py +168 -0
- kimi_cli/utils/typing.py +20 -0
- kimi_cli/utils/windows_paths.py +48 -0
- kimi_cli/vis/__init__.py +0 -0
- kimi_cli/vis/api/__init__.py +5 -0
- kimi_cli/vis/api/sessions.py +687 -0
- kimi_cli/vis/api/statistics.py +209 -0
- kimi_cli/vis/api/system.py +19 -0
- kimi_cli/vis/app.py +175 -0
- kimi_cli/web/__init__.py +5 -0
- kimi_cli/web/api/__init__.py +15 -0
- kimi_cli/web/api/config.py +208 -0
- kimi_cli/web/api/open_in.py +197 -0
- kimi_cli/web/api/sessions.py +1223 -0
- kimi_cli/web/app.py +451 -0
- kimi_cli/web/auth.py +191 -0
- kimi_cli/web/models.py +98 -0
- kimi_cli/web/runner/__init__.py +5 -0
- kimi_cli/web/runner/messages.py +57 -0
- kimi_cli/web/runner/process.py +754 -0
- kimi_cli/web/runner/worker.py +95 -0
- kimi_cli/web/store/__init__.py +1 -0
- kimi_cli/web/store/sessions.py +432 -0
- kimi_cli/wire/__init__.py +148 -0
- kimi_cli/wire/file.py +151 -0
- kimi_cli/wire/jsonrpc.py +263 -0
- kimi_cli/wire/protocol.py +2 -0
- kimi_cli/wire/root_hub.py +27 -0
- kimi_cli/wire/serde.py +26 -0
- kimi_cli/wire/server.py +1060 -0
- kimi_cli/wire/types.py +717 -0
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
# Markdown Sample Document
|
|
2
|
+
|
|
3
|
+
This is a comprehensive sample document showcasing various Markdown elements.
|
|
4
|
+
|
|
5
|
+
## Level 2 Heading
|
|
6
|
+
|
|
7
|
+
### Level 3 Heading
|
|
8
|
+
|
|
9
|
+
Here's some regular text with **bold text**, *italic text*, and `inline code`.
|
|
10
|
+
|
|
11
|
+
## Lists
|
|
12
|
+
|
|
13
|
+
### Unordered List
|
|
14
|
+
|
|
15
|
+
- First item
|
|
16
|
+
- Second item
|
|
17
|
+
- Nested item 1
|
|
18
|
+
- Nested item 2
|
|
19
|
+
- Third item
|
|
20
|
+
|
|
21
|
+
### Ordered List
|
|
22
|
+
|
|
23
|
+
1. First step
|
|
24
|
+
2. Second step
|
|
25
|
+
1. Sub-step A
|
|
26
|
+
2. Sub-step B
|
|
27
|
+
3. Third step
|
|
28
|
+
|
|
29
|
+
### Mixed List
|
|
30
|
+
|
|
31
|
+
1. First item
|
|
32
|
+
- Sub-item with bullet
|
|
33
|
+
- Another sub-item
|
|
34
|
+
2. Second item
|
|
35
|
+
1. Numbered sub-item
|
|
36
|
+
2. Another numbered sub-item
|
|
37
|
+
|
|
38
|
+
## Links and References
|
|
39
|
+
|
|
40
|
+
Here's a [link to GitHub](https://github.com) and another [relative link](../README.md).
|
|
41
|
+
|
|
42
|
+
## Code Blocks
|
|
43
|
+
|
|
44
|
+
```python
|
|
45
|
+
def hello_world():
|
|
46
|
+
"""A simple function to demonstrate code blocks."""
|
|
47
|
+
print("Hello, World!")
|
|
48
|
+
return 42
|
|
49
|
+
|
|
50
|
+
# Call the function
|
|
51
|
+
result = hello_world()
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
# Bash example
|
|
56
|
+
echo "This is a bash script"
|
|
57
|
+
ls -la /tmp
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
## Blockquotes
|
|
61
|
+
|
|
62
|
+
> This is a blockquote.
|
|
63
|
+
> It can span multiple lines.
|
|
64
|
+
>
|
|
65
|
+
> > And it can be nested too!
|
|
66
|
+
|
|
67
|
+
## Tables
|
|
68
|
+
|
|
69
|
+
| Column 1 | Column 2 | Column 3 |
|
|
70
|
+
|----------|----------|----------|
|
|
71
|
+
| Cell 1 | Cell 2 | Cell 3 |
|
|
72
|
+
| Left | Center | Right |
|
|
73
|
+
| Foo | Bar | Baz |
|
|
74
|
+
|
|
75
|
+
## Horizontal Rules
|
|
76
|
+
|
|
77
|
+
---
|
|
78
|
+
|
|
79
|
+
Here's some text after a horizontal rule.
|
|
80
|
+
|
|
81
|
+
---
|
|
82
|
+
|
|
83
|
+
## Inline Formatting
|
|
84
|
+
|
|
85
|
+
You can combine **bold and *italic*** text, or use `code` within paragraphs.
|
|
86
|
+
|
|
87
|
+
**Important**: Always test your `code` snippets before deployment.
|
|
88
|
+
|
|
89
|
+
## Advanced Features
|
|
90
|
+
|
|
91
|
+
### Task Lists
|
|
92
|
+
|
|
93
|
+
- [x] Completed task
|
|
94
|
+
- [ ] Pending task
|
|
95
|
+
- [ ] Another pending task
|
|
96
|
+
|
|
97
|
+
### Definition Lists
|
|
98
|
+
|
|
99
|
+
Term 1
|
|
100
|
+
: Definition of term 1
|
|
101
|
+
|
|
102
|
+
Term 2
|
|
103
|
+
: Definition of term 2
|
|
104
|
+
: Another definition for term 2
|
|
105
|
+
|
|
106
|
+
---
|
|
107
|
+
|
|
108
|
+
*This document demonstrates comprehensive Markdown formatting capabilities.*
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from pygments.token import (
|
|
6
|
+
Comment,
|
|
7
|
+
Generic,
|
|
8
|
+
Keyword,
|
|
9
|
+
Name,
|
|
10
|
+
Number,
|
|
11
|
+
Operator,
|
|
12
|
+
Punctuation,
|
|
13
|
+
String,
|
|
14
|
+
)
|
|
15
|
+
from pygments.token import (
|
|
16
|
+
Literal as PygmentsLiteral,
|
|
17
|
+
)
|
|
18
|
+
from pygments.token import (
|
|
19
|
+
Text as PygmentsText,
|
|
20
|
+
)
|
|
21
|
+
from pygments.token import (
|
|
22
|
+
Token as PygmentsToken,
|
|
23
|
+
)
|
|
24
|
+
from rich.style import Style
|
|
25
|
+
from rich.syntax import ANSISyntaxTheme, Syntax, SyntaxTheme
|
|
26
|
+
|
|
27
|
+
KIMI_ANSI_THEME_NAME = "kimi-ansi"
|
|
28
|
+
KIMI_ANSI_THEME = ANSISyntaxTheme(
|
|
29
|
+
{
|
|
30
|
+
PygmentsToken: Style(color="default"),
|
|
31
|
+
PygmentsText: Style(color="default"),
|
|
32
|
+
Comment: Style(color="bright_black", italic=True),
|
|
33
|
+
Keyword: Style(color="magenta"),
|
|
34
|
+
Keyword.Constant: Style(color="cyan"),
|
|
35
|
+
Keyword.Declaration: Style(color="magenta"),
|
|
36
|
+
Keyword.Namespace: Style(color="magenta"),
|
|
37
|
+
Keyword.Pseudo: Style(color="magenta"),
|
|
38
|
+
Keyword.Reserved: Style(color="magenta"),
|
|
39
|
+
Keyword.Type: Style(color="magenta"),
|
|
40
|
+
Name: Style(color="default"),
|
|
41
|
+
Name.Attribute: Style(color="cyan"),
|
|
42
|
+
Name.Builtin: Style(color="bright_yellow"),
|
|
43
|
+
Name.Builtin.Pseudo: Style(color="cyan"),
|
|
44
|
+
Name.Builtin.Type: Style(color="bright_yellow", bold=True),
|
|
45
|
+
Name.Class: Style(color="bright_yellow", bold=True),
|
|
46
|
+
Name.Constant: Style(color="cyan"),
|
|
47
|
+
Name.Decorator: Style(color="bright_cyan"),
|
|
48
|
+
Name.Entity: Style(color="bright_yellow"),
|
|
49
|
+
Name.Exception: Style(color="bright_yellow", bold=True),
|
|
50
|
+
Name.Function: Style(color="bright_cyan"),
|
|
51
|
+
Name.Label: Style(color="cyan"),
|
|
52
|
+
Name.Namespace: Style(color="magenta"),
|
|
53
|
+
Name.Other: Style(color="bright_cyan"),
|
|
54
|
+
Name.Property: Style(color="cyan"),
|
|
55
|
+
Name.Tag: Style(color="bright_green"),
|
|
56
|
+
Name.Variable: Style(color="bright_yellow"),
|
|
57
|
+
PygmentsLiteral: Style(color="bright_blue"),
|
|
58
|
+
PygmentsLiteral.Date: Style(color="bright_blue"),
|
|
59
|
+
String: Style(color="bright_blue"),
|
|
60
|
+
String.Doc: Style(color="bright_blue", italic=True),
|
|
61
|
+
String.Interpol: Style(color="bright_blue"),
|
|
62
|
+
String.Affix: Style(color="cyan"),
|
|
63
|
+
Number: Style(color="cyan"),
|
|
64
|
+
Operator: Style(color="default"),
|
|
65
|
+
Operator.Word: Style(color="magenta"),
|
|
66
|
+
Punctuation: Style(color="default"),
|
|
67
|
+
Generic.Deleted: Style(color="red"),
|
|
68
|
+
Generic.Emph: Style(italic=True),
|
|
69
|
+
Generic.Error: Style(color="bright_red", bold=True),
|
|
70
|
+
Generic.Heading: Style(color="cyan", bold=True),
|
|
71
|
+
Generic.Inserted: Style(color="green"),
|
|
72
|
+
Generic.Output: Style(color="bright_black"),
|
|
73
|
+
Generic.Prompt: Style(color="bright_cyan"),
|
|
74
|
+
Generic.Strong: Style(bold=True),
|
|
75
|
+
Generic.Subheading: Style(color="cyan"),
|
|
76
|
+
Generic.Traceback: Style(color="bright_red", bold=True),
|
|
77
|
+
}
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def resolve_code_theme(theme: str | SyntaxTheme) -> str | SyntaxTheme:
|
|
82
|
+
if isinstance(theme, str) and theme.lower() == KIMI_ANSI_THEME_NAME:
|
|
83
|
+
return KIMI_ANSI_THEME
|
|
84
|
+
return theme
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class KimiSyntax(Syntax):
|
|
88
|
+
def __init__(self, code: str, lexer: str, **kwargs: Any) -> None:
|
|
89
|
+
if "theme" not in kwargs or kwargs["theme"] is None:
|
|
90
|
+
kwargs["theme"] = KIMI_ANSI_THEME
|
|
91
|
+
super().__init__(code, lexer, **kwargs)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
if __name__ == "__main__":
|
|
95
|
+
from rich.console import Console
|
|
96
|
+
from rich.text import Text
|
|
97
|
+
|
|
98
|
+
console = Console()
|
|
99
|
+
|
|
100
|
+
examples = [
|
|
101
|
+
("diff", "diff", "@@ -1,2 +1,2 @@\n-line one\n+line uno\n"),
|
|
102
|
+
(
|
|
103
|
+
"python",
|
|
104
|
+
"python",
|
|
105
|
+
'def greet(name: str) -> str:\n return f"Hi, {name}!"\n',
|
|
106
|
+
),
|
|
107
|
+
("bash", "bash", "set -euo pipefail\nprintf '%s\\n' \"hello\"\n"),
|
|
108
|
+
]
|
|
109
|
+
|
|
110
|
+
for idx, (title, lexer, code) in enumerate(examples):
|
|
111
|
+
if idx:
|
|
112
|
+
console.print()
|
|
113
|
+
console.print(Text(f"[{title}]", style="bold"))
|
|
114
|
+
console.print(KimiSyntax(code, lexer))
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import fnmatch
|
|
4
|
+
from pathlib import PurePath
|
|
5
|
+
|
|
6
|
+
# High-confidence sensitive file patterns.
|
|
7
|
+
# Only patterns with very low false-positive risk are included.
|
|
8
|
+
SENSITIVE_PATTERNS: list[str] = [
|
|
9
|
+
# Environment variable / secrets
|
|
10
|
+
".env",
|
|
11
|
+
".env.*",
|
|
12
|
+
# SSH private keys
|
|
13
|
+
"id_rsa",
|
|
14
|
+
"id_ed25519",
|
|
15
|
+
"id_ecdsa",
|
|
16
|
+
# Cloud credentials (path-based, also bare name for stripped-path scenarios)
|
|
17
|
+
".aws/credentials",
|
|
18
|
+
".gcp/credentials",
|
|
19
|
+
"credentials",
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
# Template/example files that match .env.* but are not sensitive.
|
|
23
|
+
SENSITIVE_EXEMPTIONS: set[str] = {
|
|
24
|
+
".env.example",
|
|
25
|
+
".env.sample",
|
|
26
|
+
".env.template",
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def is_sensitive_file(path: str) -> bool:
|
|
31
|
+
"""Check if a file path matches any sensitive file pattern."""
|
|
32
|
+
name = PurePath(path).name
|
|
33
|
+
if name in SENSITIVE_EXEMPTIONS:
|
|
34
|
+
return False
|
|
35
|
+
for pattern in SENSITIVE_PATTERNS:
|
|
36
|
+
if "/" in pattern:
|
|
37
|
+
if path.endswith(pattern) or ("/" + pattern) in path:
|
|
38
|
+
return True
|
|
39
|
+
else:
|
|
40
|
+
if fnmatch.fnmatch(name, pattern):
|
|
41
|
+
return True
|
|
42
|
+
return False
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def sensitive_file_warning(paths: list[str]) -> str:
|
|
46
|
+
"""Generate a warning message for sensitive files that were skipped."""
|
|
47
|
+
names = sorted({PurePath(p).name for p in paths})
|
|
48
|
+
file_list = ", ".join(names[:5])
|
|
49
|
+
if len(names) > 5:
|
|
50
|
+
file_list += f", ... ({len(names)} files total)"
|
|
51
|
+
return (
|
|
52
|
+
f"Skipped {len(paths)} sensitive file(s) ({file_list}) "
|
|
53
|
+
f"to protect secrets. These files may contain credentials or private keys."
|
|
54
|
+
)
|
kimi_cli/utils/server.py
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"""Shared utilities for kimi vis and kimi web server startup."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import importlib
|
|
6
|
+
import socket
|
|
7
|
+
import textwrap
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def get_address_family(host: str) -> socket.AddressFamily:
|
|
11
|
+
"""Return AF_INET6 for IPv6 addresses, AF_INET for IPv4 and hostnames."""
|
|
12
|
+
return socket.AF_INET6 if ":" in host else socket.AF_INET
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def format_url(host: str, port: int) -> str:
|
|
16
|
+
"""Build ``http://host:port``, bracketing IPv6 literals per RFC 2732."""
|
|
17
|
+
if ":" in host:
|
|
18
|
+
return f"http://[{host}]:{port}"
|
|
19
|
+
return f"http://{host}:{port}"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def is_local_host(host: str) -> bool:
|
|
23
|
+
"""Check whether *host* resolves to a loopback address."""
|
|
24
|
+
return host in {"127.0.0.1", "localhost", "::1"}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def find_available_port(host: str, start_port: int, max_attempts: int = 10) -> int:
|
|
28
|
+
"""Find an available port starting from *start_port*.
|
|
29
|
+
|
|
30
|
+
Raises ``RuntimeError`` if no port is available within the range.
|
|
31
|
+
"""
|
|
32
|
+
if max_attempts <= 0:
|
|
33
|
+
raise ValueError("max_attempts must be positive")
|
|
34
|
+
if start_port < 1 or start_port > 65535:
|
|
35
|
+
raise ValueError("start_port must be between 1 and 65535")
|
|
36
|
+
|
|
37
|
+
family = get_address_family(host)
|
|
38
|
+
for offset in range(max_attempts):
|
|
39
|
+
port = start_port + offset
|
|
40
|
+
with socket.socket(family, socket.SOCK_STREAM) as s:
|
|
41
|
+
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
|
42
|
+
try:
|
|
43
|
+
s.bind((host, port))
|
|
44
|
+
return port
|
|
45
|
+
except OSError:
|
|
46
|
+
continue
|
|
47
|
+
raise RuntimeError(
|
|
48
|
+
f"Cannot find available port in range {start_port}-{start_port + max_attempts - 1}"
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def get_network_addresses() -> list[str]:
|
|
53
|
+
"""Get non-loopback IPv4 addresses for this machine."""
|
|
54
|
+
addresses: list[str] = []
|
|
55
|
+
|
|
56
|
+
try:
|
|
57
|
+
hostname = socket.gethostname()
|
|
58
|
+
for info in socket.getaddrinfo(hostname, None, socket.AF_INET):
|
|
59
|
+
ip = info[4][0]
|
|
60
|
+
if isinstance(ip, str) and not ip.startswith("127.") and ip not in addresses:
|
|
61
|
+
addresses.append(ip)
|
|
62
|
+
except OSError:
|
|
63
|
+
pass
|
|
64
|
+
|
|
65
|
+
try:
|
|
66
|
+
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s:
|
|
67
|
+
s.connect(("8.8.8.8", 80))
|
|
68
|
+
ip = s.getsockname()[0]
|
|
69
|
+
if ip and not ip.startswith("127.") and ip not in addresses:
|
|
70
|
+
addresses.append(ip)
|
|
71
|
+
except OSError:
|
|
72
|
+
pass
|
|
73
|
+
|
|
74
|
+
try:
|
|
75
|
+
netifaces = importlib.import_module("netifaces")
|
|
76
|
+
for interface in netifaces.interfaces():
|
|
77
|
+
addrs = netifaces.ifaddresses(interface)
|
|
78
|
+
if netifaces.AF_INET in addrs:
|
|
79
|
+
for addr_info in addrs[netifaces.AF_INET]:
|
|
80
|
+
addr = addr_info.get("addr")
|
|
81
|
+
if addr and not addr.startswith("127.") and addr not in addresses:
|
|
82
|
+
addresses.append(addr)
|
|
83
|
+
except (ImportError, Exception):
|
|
84
|
+
pass
|
|
85
|
+
|
|
86
|
+
return addresses
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def print_banner(lines: list[str]) -> None:
|
|
90
|
+
"""Print a boxed banner with tag conventions (<center>, <nowrap>, <hr>)."""
|
|
91
|
+
processed: list[str] = []
|
|
92
|
+
for line in lines:
|
|
93
|
+
if line == "<hr>":
|
|
94
|
+
processed.append(line)
|
|
95
|
+
elif not line:
|
|
96
|
+
processed.append("")
|
|
97
|
+
elif line.startswith("<center>") or line.startswith("<nowrap>"):
|
|
98
|
+
processed.append(line)
|
|
99
|
+
else:
|
|
100
|
+
processed.extend(textwrap.wrap(line, width=78))
|
|
101
|
+
|
|
102
|
+
def strip_tags(s: str) -> str:
|
|
103
|
+
return s.removeprefix("<center>").removeprefix("<nowrap>")
|
|
104
|
+
|
|
105
|
+
content_lines = [strip_tags(line) for line in processed if line != "<hr>"]
|
|
106
|
+
width = max(60, *(len(line) for line in content_lines))
|
|
107
|
+
top = "+" + "=" * (width + 2) + "+"
|
|
108
|
+
|
|
109
|
+
print(top)
|
|
110
|
+
for line in processed:
|
|
111
|
+
if line == "<hr>":
|
|
112
|
+
print("|" + "-" * (width + 2) + "|")
|
|
113
|
+
elif line.startswith("<center>"):
|
|
114
|
+
content = line.removeprefix("<center>")
|
|
115
|
+
print(f"| {content.center(width)} |")
|
|
116
|
+
elif line.startswith("<nowrap>"):
|
|
117
|
+
content = line.removeprefix("<nowrap>")
|
|
118
|
+
print(f"| {content.ljust(width)} |")
|
|
119
|
+
else:
|
|
120
|
+
print(f"| {line.ljust(width)} |")
|
|
121
|
+
print(top)
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""Defensive rewrites applied to shell commands before execution.
|
|
2
|
+
|
|
3
|
+
The model occasionally hallucinates Windows CMD syntax even when running on
|
|
4
|
+
git-bash (which is POSIX). One such hallucination has caused real damage:
|
|
5
|
+
``cmd 2>nul`` in git-bash creates a literal file named ``nul`` in cwd, which
|
|
6
|
+
is a Windows reserved device name that breaks ``git add .`` / ``git clone``.
|
|
7
|
+
|
|
8
|
+
The fix here mirrors claude-code's ``rewriteWindowsNullRedirect``
|
|
9
|
+
(see anthropics/claude-code#4928): rewrite the bad redirect to ``/dev/null``
|
|
10
|
+
before the command reaches the shell.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import re
|
|
16
|
+
|
|
17
|
+
# Match `>nul`, `> NUL`, `2>nul`, `&>nul`, `>>nul` (case-insensitive),
|
|
18
|
+
# but NOT `>null`, `>nullable`, `>nul.txt`, `cat nul.txt`.
|
|
19
|
+
#
|
|
20
|
+
# Group 1 captures the redirect operator + optional whitespace, so the rewrite
|
|
21
|
+
# preserves the original spacing (e.g. `2> nul` -> `2> /dev/null`).
|
|
22
|
+
_NUL_REDIRECT = re.compile(r"(\d?&?>+\s*)[Nn][Uu][Ll](?=\s|$|[|&;)\n])")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def rewrite_windows_null_redirect(command: str, *, on_windows: bool) -> str:
|
|
26
|
+
"""Rewrite Windows-style ``>nul`` redirects to POSIX ``/dev/null``.
|
|
27
|
+
|
|
28
|
+
Only active when ``on_windows`` is True. On Linux/macOS, ``>nul`` is a
|
|
29
|
+
legitimate redirect to a file named ``nul`` and must not be rewritten.
|
|
30
|
+
|
|
31
|
+
The regex's lookahead requires a shell-meaningful character after ``nul``
|
|
32
|
+
(whitespace, end-of-string, or one of ``|&;)\\n``), so quoted forms like
|
|
33
|
+
``echo ">nul"`` slip through unmolested — a useful happy accident, since
|
|
34
|
+
rewriting inside string literals would corrupt user data.
|
|
35
|
+
"""
|
|
36
|
+
if not on_windows:
|
|
37
|
+
return command
|
|
38
|
+
return _NUL_REDIRECT.sub(r"\1/dev/null", command)
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import contextlib
|
|
5
|
+
import signal
|
|
6
|
+
from collections.abc import Callable
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def install_sigint_handler(
|
|
10
|
+
loop: asyncio.AbstractEventLoop, handler: Callable[[], None]
|
|
11
|
+
) -> Callable[[], None]:
|
|
12
|
+
"""
|
|
13
|
+
Install a SIGINT handler that works on Unix and Windows.
|
|
14
|
+
|
|
15
|
+
On Unix event loops, prefer `loop.add_signal_handler`.
|
|
16
|
+
On Windows (or other platforms) where it is not implemented, fall back to
|
|
17
|
+
`signal.signal`. The fallback cannot be removed from the loop, but we
|
|
18
|
+
restore the previous handler on uninstall.
|
|
19
|
+
|
|
20
|
+
Returns:
|
|
21
|
+
A function that removes the installed handler. It is guaranteed that
|
|
22
|
+
no exceptions are raised when calling the returned function.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
try:
|
|
26
|
+
loop.add_signal_handler(signal.SIGINT, handler)
|
|
27
|
+
|
|
28
|
+
def remove() -> None:
|
|
29
|
+
with contextlib.suppress(RuntimeError):
|
|
30
|
+
loop.remove_signal_handler(signal.SIGINT)
|
|
31
|
+
|
|
32
|
+
return remove
|
|
33
|
+
except RuntimeError:
|
|
34
|
+
# Windows ProactorEventLoop and some environments do not support
|
|
35
|
+
# add_signal_handler. Use synchronous signal handling as a fallback.
|
|
36
|
+
previous = signal.getsignal(signal.SIGINT)
|
|
37
|
+
signal.signal(signal.SIGINT, lambda signum, frame: handler())
|
|
38
|
+
|
|
39
|
+
def remove() -> None:
|
|
40
|
+
with contextlib.suppress(RuntimeError):
|
|
41
|
+
signal.signal(signal.SIGINT, previous)
|
|
42
|
+
|
|
43
|
+
return remove
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import re
|
|
2
|
+
from collections.abc import Awaitable, Callable, Sequence
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import overload
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
8
|
+
class SlashCommand[F: Callable[..., None | Awaitable[None]]]:
|
|
9
|
+
name: str
|
|
10
|
+
description: str
|
|
11
|
+
func: F
|
|
12
|
+
aliases: list[str]
|
|
13
|
+
|
|
14
|
+
def display_name(self, trigger: str | None = None) -> str:
|
|
15
|
+
"""/name for canonical triggers, /name (alias) for alias triggers."""
|
|
16
|
+
if trigger is not None and trigger != self.name and trigger in self.aliases:
|
|
17
|
+
return f"/{self.name} ({trigger})"
|
|
18
|
+
return f"/{self.name}"
|
|
19
|
+
|
|
20
|
+
def slash_name(self):
|
|
21
|
+
"""/name (aliases)"""
|
|
22
|
+
if self.aliases:
|
|
23
|
+
return f"/{self.name} ({', '.join(self.aliases)})"
|
|
24
|
+
return f"/{self.name}"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class SlashCommandRegistry[F: Callable[..., None | Awaitable[None]]]:
|
|
28
|
+
"""Registry for slash commands."""
|
|
29
|
+
|
|
30
|
+
def __init__(self) -> None:
|
|
31
|
+
self._commands: dict[str, SlashCommand[F]] = {}
|
|
32
|
+
"""Primary name -> SlashCommand"""
|
|
33
|
+
self._command_aliases: dict[str, SlashCommand[F]] = {}
|
|
34
|
+
"""Primary name or alias -> SlashCommand"""
|
|
35
|
+
|
|
36
|
+
@overload
|
|
37
|
+
def command(self, func: F, /) -> F: ...
|
|
38
|
+
|
|
39
|
+
@overload
|
|
40
|
+
def command(
|
|
41
|
+
self,
|
|
42
|
+
*,
|
|
43
|
+
name: str | None = None,
|
|
44
|
+
aliases: Sequence[str] | None = None,
|
|
45
|
+
) -> Callable[[F], F]: ...
|
|
46
|
+
|
|
47
|
+
def command(
|
|
48
|
+
self,
|
|
49
|
+
func: F | None = None,
|
|
50
|
+
*,
|
|
51
|
+
name: str | None = None,
|
|
52
|
+
aliases: Sequence[str] | None = None,
|
|
53
|
+
) -> F | Callable[[F], F]:
|
|
54
|
+
"""
|
|
55
|
+
Decorator to register a slash command with optional custom name and aliases.
|
|
56
|
+
|
|
57
|
+
Usage examples:
|
|
58
|
+
@registry.command
|
|
59
|
+
def help(app: App, args: str): ...
|
|
60
|
+
|
|
61
|
+
@registry.command(name="run")
|
|
62
|
+
def start(app: App, args: str): ...
|
|
63
|
+
|
|
64
|
+
@registry.command(aliases=["h", "?", "assist"])
|
|
65
|
+
def help(app: App, args: str): ...
|
|
66
|
+
"""
|
|
67
|
+
|
|
68
|
+
def _register(f: F) -> F:
|
|
69
|
+
primary = name or f.__name__
|
|
70
|
+
alias_list = list(aliases) if aliases else []
|
|
71
|
+
|
|
72
|
+
# Create the primary command with aliases
|
|
73
|
+
cmd = SlashCommand[F](
|
|
74
|
+
name=primary,
|
|
75
|
+
description=(f.__doc__ or "").strip(),
|
|
76
|
+
func=f,
|
|
77
|
+
aliases=alias_list,
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
# Register primary command
|
|
81
|
+
self._commands[primary] = cmd
|
|
82
|
+
self._command_aliases[primary] = cmd
|
|
83
|
+
|
|
84
|
+
# Register aliases pointing to the same command
|
|
85
|
+
for alias in alias_list:
|
|
86
|
+
self._command_aliases[alias] = cmd
|
|
87
|
+
|
|
88
|
+
return f
|
|
89
|
+
|
|
90
|
+
if func is not None:
|
|
91
|
+
return _register(func)
|
|
92
|
+
return _register
|
|
93
|
+
|
|
94
|
+
def find_command(self, name: str) -> SlashCommand[F] | None:
|
|
95
|
+
return self._command_aliases.get(name)
|
|
96
|
+
|
|
97
|
+
def list_commands(self) -> list[SlashCommand[F]]:
|
|
98
|
+
"""Get all unique primary slash commands (without duplicating aliases)."""
|
|
99
|
+
return list(self._commands.values())
|
|
100
|
+
|
|
101
|
+
def iter_command_entries(self) -> list[tuple[str, SlashCommand[F]]]:
|
|
102
|
+
"""Get canonical and alias entries as (trigger, command) pairs."""
|
|
103
|
+
entries: list[tuple[str, SlashCommand[F]]] = []
|
|
104
|
+
for cmd in self._commands.values():
|
|
105
|
+
entries.append((cmd.name, cmd))
|
|
106
|
+
seen = {cmd.name}
|
|
107
|
+
for alias in cmd.aliases:
|
|
108
|
+
if alias in seen:
|
|
109
|
+
continue
|
|
110
|
+
if self._command_aliases.get(alias) is not cmd:
|
|
111
|
+
continue
|
|
112
|
+
entries.append((alias, cmd))
|
|
113
|
+
seen.add(alias)
|
|
114
|
+
return entries
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
118
|
+
class SlashCommandCall:
|
|
119
|
+
name: str
|
|
120
|
+
args: str
|
|
121
|
+
raw_input: str
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def parse_slash_command_call(user_input: str) -> SlashCommandCall | None:
|
|
125
|
+
"""
|
|
126
|
+
Parse a slash command call from user input.
|
|
127
|
+
|
|
128
|
+
Returns:
|
|
129
|
+
SlashCommandCall if a slash command is found, else None. The `args` field contains
|
|
130
|
+
the raw argument string after the command name.
|
|
131
|
+
"""
|
|
132
|
+
user_input = user_input.strip()
|
|
133
|
+
if not user_input or not user_input.startswith("/"):
|
|
134
|
+
return None
|
|
135
|
+
|
|
136
|
+
name_match = re.match(r"^\/([a-zA-Z0-9_-]+(?::[a-zA-Z0-9_-]+)*)", user_input)
|
|
137
|
+
|
|
138
|
+
if not name_match:
|
|
139
|
+
return None
|
|
140
|
+
|
|
141
|
+
command_name = name_match.group(1)
|
|
142
|
+
if len(user_input) > name_match.end() and not user_input[name_match.end()].isspace():
|
|
143
|
+
return None
|
|
144
|
+
raw_args = user_input[name_match.end() :].lstrip()
|
|
145
|
+
return SlashCommandCall(name=command_name, args=raw_args, raw_input=user_input)
|
kimi_cli/utils/string.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import random
|
|
4
|
+
import re
|
|
5
|
+
import string
|
|
6
|
+
|
|
7
|
+
_NEWLINE_RE = re.compile(r"[\r\n]+")
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def shorten(text: str, *, width: int, placeholder: str = "…") -> str:
|
|
11
|
+
"""Shorten text to at most *width* characters.
|
|
12
|
+
|
|
13
|
+
Normalises whitespace, then truncates — preferring a word boundary
|
|
14
|
+
when one exists near the cut point, but falling back to a hard cut
|
|
15
|
+
so that CJK text without spaces won't collapse to just the placeholder.
|
|
16
|
+
"""
|
|
17
|
+
text = " ".join(text.split())
|
|
18
|
+
if len(text) <= width:
|
|
19
|
+
return text
|
|
20
|
+
cut = width - len(placeholder)
|
|
21
|
+
if cut <= 0:
|
|
22
|
+
return text[:width]
|
|
23
|
+
space = text.rfind(" ", 0, cut + 1)
|
|
24
|
+
if space > 0:
|
|
25
|
+
cut = space
|
|
26
|
+
return text[:cut].rstrip() + placeholder
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def shorten_middle(text: str, width: int, remove_newline: bool = True) -> str:
|
|
30
|
+
"""Shorten the text by inserting ellipsis in the middle."""
|
|
31
|
+
if len(text) <= width:
|
|
32
|
+
return text
|
|
33
|
+
if remove_newline:
|
|
34
|
+
text = _NEWLINE_RE.sub(" ", text)
|
|
35
|
+
return text[: width // 2] + "..." + text[-width // 2 :]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def random_string(length: int = 8) -> str:
|
|
39
|
+
"""Generate a random string of fixed length."""
|
|
40
|
+
letters = string.ascii_lowercase
|
|
41
|
+
return "".join(random.choice(letters) for _ in range(length))
|