Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3997a0a721 |
@@ -1,36 +0,0 @@
|
|||||||
name: Lint and Type Check
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
paths:
|
|
||||||
- '**.py'
|
|
||||||
- '.pylintrc'
|
|
||||||
- '.gitea/workflows/lint.yml'
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
lint:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v3
|
|
||||||
|
|
||||||
- name: Set up Python
|
|
||||||
uses: actions/setup-python@v4
|
|
||||||
with:
|
|
||||||
python-version: '3.12'
|
|
||||||
cache: 'pip'
|
|
||||||
cache-dependency-path: requirements-dev.txt
|
|
||||||
|
|
||||||
- name: Install dev dependencies
|
|
||||||
run: |
|
|
||||||
python -m pip install --upgrade pip
|
|
||||||
pip install -r requirements-dev.txt
|
|
||||||
|
|
||||||
- name: Run pylint
|
|
||||||
run: |
|
|
||||||
# Run pylint on all Python files in the repo
|
|
||||||
find . -name '*.py' -not -path './.venv/*' -not -path './.git/*' | xargs pylint --fail-under=8.0 || true
|
|
||||||
|
|
||||||
- name: Run pyright
|
|
||||||
run: |
|
|
||||||
# Run pyright type checking
|
|
||||||
pyright .
|
|
||||||
@@ -1,624 +0,0 @@
|
|||||||
[MAIN]
|
|
||||||
|
|
||||||
# Analyse import fallback blocks. This can be used to support both Python 2 and
|
|
||||||
# 3 compatible code, which means that the block might have code that exists
|
|
||||||
# only in one or another interpreter, leading to false positives when analysed.
|
|
||||||
analyse-fallback-blocks=no
|
|
||||||
|
|
||||||
# Clear in-memory caches upon conclusion of linting. Useful if running pylint
|
|
||||||
# in a server-like mode.
|
|
||||||
clear-cache-post-run=no
|
|
||||||
|
|
||||||
# Load and enable all available extensions. Use --list-extensions to see a list
|
|
||||||
# all available extensions.
|
|
||||||
#enable-all-extensions=
|
|
||||||
|
|
||||||
# In error mode, messages with a category besides ERROR or FATAL are
|
|
||||||
# suppressed, and no reports are done by default. Error mode is compatible with
|
|
||||||
# disabling specific errors.
|
|
||||||
#errors-only=
|
|
||||||
|
|
||||||
# Always return a 0 (non-error) status code, even if lint errors are found.
|
|
||||||
# This is primarily useful in continuous integration scripts.
|
|
||||||
#exit-zero=
|
|
||||||
|
|
||||||
# A comma-separated list of package or module names from where C extensions may
|
|
||||||
# be loaded. Extensions are loading into the active Python interpreter and may
|
|
||||||
# run arbitrary code.
|
|
||||||
extension-pkg-allow-list=
|
|
||||||
|
|
||||||
# A comma-separated list of package or module names from where C extensions may
|
|
||||||
# be loaded. Extensions are loading into the active Python interpreter and may
|
|
||||||
# run arbitrary code. (This is an alternative name to extension-pkg-allow-list
|
|
||||||
# for backward compatibility.)
|
|
||||||
extension-pkg-whitelist=
|
|
||||||
|
|
||||||
# Return non-zero exit code if any of these messages/categories are detected,
|
|
||||||
# even if score is above --fail-under value. Syntax same as enable. Messages
|
|
||||||
# specified are enabled, while categories only check already-enabled messages.
|
|
||||||
fail-on=
|
|
||||||
|
|
||||||
# Specify a score threshold under which the program will exit with error.
|
|
||||||
fail-under=10
|
|
||||||
|
|
||||||
# Interpret the stdin as a python script, whose filename needs to be passed as
|
|
||||||
# the module_or_package argument.
|
|
||||||
#from-stdin=
|
|
||||||
|
|
||||||
# Files or directories to be skipped. They should be base names, not paths.
|
|
||||||
ignore=CVS
|
|
||||||
|
|
||||||
# Add files or directories matching the regular expressions patterns to the
|
|
||||||
# ignore-list. The regex matches against paths and can be in Posix or Windows
|
|
||||||
# format. Because '\\' represents the directory delimiter on Windows systems,
|
|
||||||
# it can't be used as an escape character.
|
|
||||||
ignore-paths=
|
|
||||||
|
|
||||||
# Files or directories matching the regular expression patterns are skipped.
|
|
||||||
# The regex matches against base names, not paths. The default value ignores
|
|
||||||
# Emacs file locks
|
|
||||||
ignore-patterns=^\.#
|
|
||||||
|
|
||||||
# List of module names for which member attributes should not be checked and
|
|
||||||
# will not be imported (useful for modules/projects where namespaces are
|
|
||||||
# manipulated during runtime and thus existing member attributes cannot be
|
|
||||||
# deduced by static analysis). It supports qualified module names, as well as
|
|
||||||
# Unix pattern matching.
|
|
||||||
ignored-modules=
|
|
||||||
|
|
||||||
# Python code to execute, usually for sys.path manipulation such as
|
|
||||||
# pygtk.require().
|
|
||||||
#init-hook=
|
|
||||||
|
|
||||||
# Use multiple processes to speed up Pylint. Specifying 0 will auto-detect the
|
|
||||||
# number of processors available to use, and will cap the count on Windows to
|
|
||||||
# avoid hangs.
|
|
||||||
jobs=1
|
|
||||||
|
|
||||||
# Control the amount of potential inferred values when inferring a single
|
|
||||||
# object. This can help the performance when dealing with large functions or
|
|
||||||
# complex, nested conditions.
|
|
||||||
limit-inference-results=100
|
|
||||||
|
|
||||||
# List of plugins (as comma separated values of python module names) to load,
|
|
||||||
# usually to register additional checkers.
|
|
||||||
load-plugins=
|
|
||||||
|
|
||||||
# Pickle collected data for later comparisons.
|
|
||||||
persistent=yes
|
|
||||||
|
|
||||||
# Resolve imports to .pyi stubs if available. May reduce no-member messages and
|
|
||||||
# increase not-an-iterable messages.
|
|
||||||
prefer-stubs=no
|
|
||||||
|
|
||||||
# Minimum Python version to use for version dependent checks. Will default to
|
|
||||||
# the version used to run pylint.
|
|
||||||
py-version=3.14
|
|
||||||
|
|
||||||
# Discover python modules and packages in the file system subtree.
|
|
||||||
recursive=no
|
|
||||||
|
|
||||||
# Add paths to the list of the source roots. Supports globbing patterns. The
|
|
||||||
# source root is an absolute path or a path relative to the current working
|
|
||||||
# directory used to determine a package namespace for modules located under the
|
|
||||||
# source root.
|
|
||||||
source-roots=
|
|
||||||
|
|
||||||
# Allow loading of arbitrary C extensions. Extensions are imported into the
|
|
||||||
# active Python interpreter and may run arbitrary code.
|
|
||||||
unsafe-load-any-extension=no
|
|
||||||
|
|
||||||
# In verbose mode, extra non-checker-related info will be displayed.
|
|
||||||
#verbose=
|
|
||||||
|
|
||||||
|
|
||||||
[BASIC]
|
|
||||||
|
|
||||||
# Naming style matching correct argument names.
|
|
||||||
argument-naming-style=snake_case
|
|
||||||
|
|
||||||
# Regular expression matching correct argument names. Overrides argument-
|
|
||||||
# naming-style. If left empty, argument names will be checked with the set
|
|
||||||
# naming style.
|
|
||||||
#argument-rgx=
|
|
||||||
|
|
||||||
# Naming style matching correct attribute names.
|
|
||||||
attr-naming-style=snake_case
|
|
||||||
|
|
||||||
# Regular expression matching correct attribute names. Overrides attr-naming-
|
|
||||||
# style. If left empty, attribute names will be checked with the set naming
|
|
||||||
# style.
|
|
||||||
#attr-rgx=
|
|
||||||
|
|
||||||
# Bad variable names which should always be refused, separated by a comma.
|
|
||||||
bad-names=foo,
|
|
||||||
bar,
|
|
||||||
baz,
|
|
||||||
toto,
|
|
||||||
tutu,
|
|
||||||
tata
|
|
||||||
|
|
||||||
# Bad variable names regexes, separated by a comma. If names match any regex,
|
|
||||||
# they will always be refused
|
|
||||||
bad-names-rgxs=
|
|
||||||
|
|
||||||
# Naming style matching correct class attribute names.
|
|
||||||
class-attribute-naming-style=any
|
|
||||||
|
|
||||||
# Regular expression matching correct class attribute names. Overrides class-
|
|
||||||
# attribute-naming-style. If left empty, class attribute names will be checked
|
|
||||||
# with the set naming style.
|
|
||||||
#class-attribute-rgx=
|
|
||||||
|
|
||||||
# Naming style matching correct class constant names.
|
|
||||||
class-const-naming-style=UPPER_CASE
|
|
||||||
|
|
||||||
# Regular expression matching correct class constant names. Overrides class-
|
|
||||||
# const-naming-style. If left empty, class constant names will be checked with
|
|
||||||
# the set naming style.
|
|
||||||
#class-const-rgx=
|
|
||||||
|
|
||||||
# Naming style matching correct class names.
|
|
||||||
class-naming-style=PascalCase
|
|
||||||
|
|
||||||
# Regular expression matching correct class names. Overrides class-naming-
|
|
||||||
# style. If left empty, class names will be checked with the set naming style.
|
|
||||||
#class-rgx=
|
|
||||||
|
|
||||||
# Naming style matching correct constant names.
|
|
||||||
const-naming-style=UPPER_CASE
|
|
||||||
|
|
||||||
# Regular expression matching correct constant names. Overrides const-naming-
|
|
||||||
# style. If left empty, constant names will be checked with the set naming
|
|
||||||
# style.
|
|
||||||
#const-rgx=
|
|
||||||
|
|
||||||
# Minimum line length for functions/classes that require docstrings, shorter
|
|
||||||
# ones are exempt.
|
|
||||||
docstring-min-length=-1
|
|
||||||
|
|
||||||
# Naming style matching correct function names.
|
|
||||||
function-naming-style=snake_case
|
|
||||||
|
|
||||||
# Regular expression matching correct function names. Overrides function-
|
|
||||||
# naming-style. If left empty, function names will be checked with the set
|
|
||||||
# naming style.
|
|
||||||
#function-rgx=
|
|
||||||
|
|
||||||
# Good variable names which should always be accepted, separated by a comma.
|
|
||||||
good-names=i,
|
|
||||||
j,
|
|
||||||
k,
|
|
||||||
ex,
|
|
||||||
Run,
|
|
||||||
_
|
|
||||||
|
|
||||||
# Good variable names regexes, separated by a comma. If names match any regex,
|
|
||||||
# they will always be accepted
|
|
||||||
good-names-rgxs=
|
|
||||||
|
|
||||||
# Include a hint for the correct naming format with invalid-name.
|
|
||||||
include-naming-hint=no
|
|
||||||
|
|
||||||
# Naming style matching correct inline iteration names.
|
|
||||||
inlinevar-naming-style=any
|
|
||||||
|
|
||||||
# Regular expression matching correct inline iteration names. Overrides
|
|
||||||
# inlinevar-naming-style. If left empty, inline iteration names will be checked
|
|
||||||
# with the set naming style.
|
|
||||||
#inlinevar-rgx=
|
|
||||||
|
|
||||||
# Naming style matching correct method names.
|
|
||||||
method-naming-style=snake_case
|
|
||||||
|
|
||||||
# Regular expression matching correct method names. Overrides method-naming-
|
|
||||||
# style. If left empty, method names will be checked with the set naming style.
|
|
||||||
#method-rgx=
|
|
||||||
|
|
||||||
# Naming style matching correct module names.
|
|
||||||
module-naming-style=snake_case
|
|
||||||
|
|
||||||
# Regular expression matching correct module names. Overrides module-naming-
|
|
||||||
# style. If left empty, module names will be checked with the set naming style.
|
|
||||||
#module-rgx=
|
|
||||||
|
|
||||||
# Colon-delimited sets of names that determine each other's naming style when
|
|
||||||
# the name regexes allow several styles.
|
|
||||||
name-group=
|
|
||||||
|
|
||||||
# Regular expression which should only match function or class names that do
|
|
||||||
# not require a docstring.
|
|
||||||
no-docstring-rgx=^_
|
|
||||||
|
|
||||||
# Regular expression matching correct parameter specification variable names.
|
|
||||||
# If left empty, parameter specification variable names will be checked with
|
|
||||||
# the set naming style.
|
|
||||||
#paramspec-rgx=
|
|
||||||
|
|
||||||
# List of decorators that produce properties, such as abc.abstractproperty. Add
|
|
||||||
# to this list to register other decorators that produce valid properties.
|
|
||||||
# These decorators are taken in consideration only for invalid-name.
|
|
||||||
property-classes=abc.abstractproperty
|
|
||||||
|
|
||||||
# Regular expression matching correct type alias names. If left empty, type
|
|
||||||
# alias names will be checked with the set naming style.
|
|
||||||
#typealias-rgx=
|
|
||||||
|
|
||||||
# Regular expression matching correct type variable names. If left empty, type
|
|
||||||
# variable names will be checked with the set naming style.
|
|
||||||
#typevar-rgx=
|
|
||||||
|
|
||||||
# Regular expression matching correct type variable tuple names. If left empty,
|
|
||||||
# type variable tuple names will be checked with the set naming style.
|
|
||||||
#typevartuple-rgx=
|
|
||||||
|
|
||||||
# Naming style matching correct variable names.
|
|
||||||
variable-naming-style=snake_case
|
|
||||||
|
|
||||||
# Regular expression matching correct variable names. Overrides variable-
|
|
||||||
# naming-style. If left empty, variable names will be checked with the set
|
|
||||||
# naming style.
|
|
||||||
#variable-rgx=
|
|
||||||
|
|
||||||
|
|
||||||
[CLASSES]
|
|
||||||
|
|
||||||
# Warn about protected attribute access inside special methods
|
|
||||||
check-protected-access-in-special-methods=no
|
|
||||||
|
|
||||||
# List of method names used to declare (i.e. assign) instance attributes.
|
|
||||||
defining-attr-methods=__init__,
|
|
||||||
__new__,
|
|
||||||
setUp,
|
|
||||||
asyncSetUp,
|
|
||||||
__post_init__
|
|
||||||
|
|
||||||
# List of member names, which should be excluded from the protected access
|
|
||||||
# warning.
|
|
||||||
exclude-protected=_asdict,_fields,_replace,_source,_make,os._exit
|
|
||||||
|
|
||||||
# List of valid names for the first argument in a class method.
|
|
||||||
valid-classmethod-first-arg=cls
|
|
||||||
|
|
||||||
# List of valid names for the first argument in a metaclass class method.
|
|
||||||
valid-metaclass-classmethod-first-arg=mcs
|
|
||||||
|
|
||||||
|
|
||||||
[DESIGN]
|
|
||||||
|
|
||||||
# List of regular expressions of class ancestor names to ignore when counting
|
|
||||||
# public methods (see R0903)
|
|
||||||
exclude-too-few-public-methods=
|
|
||||||
|
|
||||||
# List of qualified class names to ignore when counting class parents (see
|
|
||||||
# R0901)
|
|
||||||
ignored-parents=
|
|
||||||
|
|
||||||
# Maximum number of arguments for function / method.
|
|
||||||
max-args=5
|
|
||||||
|
|
||||||
# Maximum number of attributes for a class (see R0902).
|
|
||||||
max-attributes=7
|
|
||||||
|
|
||||||
# Maximum number of boolean expressions in an if statement (see R0916).
|
|
||||||
max-bool-expr=5
|
|
||||||
|
|
||||||
# Maximum number of branch for function / method body.
|
|
||||||
max-branches=12
|
|
||||||
|
|
||||||
# Maximum number of locals for function / method body.
|
|
||||||
max-locals=15
|
|
||||||
|
|
||||||
# Maximum number of parents for a class (see R0901).
|
|
||||||
max-parents=7
|
|
||||||
|
|
||||||
# Maximum number of positional arguments for function / method.
|
|
||||||
max-positional-arguments=5
|
|
||||||
|
|
||||||
# Maximum number of public methods for a class (see R0904).
|
|
||||||
max-public-methods=20
|
|
||||||
|
|
||||||
# Maximum number of return / yield for function / method body.
|
|
||||||
max-returns=6
|
|
||||||
|
|
||||||
# Maximum number of statements in function / method body.
|
|
||||||
max-statements=50
|
|
||||||
|
|
||||||
# Minimum number of public methods for a class (see R0903).
|
|
||||||
min-public-methods=2
|
|
||||||
|
|
||||||
|
|
||||||
[EXCEPTIONS]
|
|
||||||
|
|
||||||
# Exceptions that will emit a warning when caught.
|
|
||||||
overgeneral-exceptions=builtins.BaseException,builtins.Exception
|
|
||||||
|
|
||||||
|
|
||||||
[FORMAT]
|
|
||||||
|
|
||||||
# Expected format of line ending, e.g. empty (any line ending), LF or CRLF.
|
|
||||||
expected-line-ending-format=
|
|
||||||
|
|
||||||
# Regexp for a line that is allowed to be longer than the limit.
|
|
||||||
ignore-long-lines=^\s*(# )?<?https?://\S+>?$
|
|
||||||
|
|
||||||
# Number of spaces of indent required inside a hanging or continued line.
|
|
||||||
indent-after-paren=4
|
|
||||||
|
|
||||||
# String used as indentation unit. This is usually " " (4 spaces) or "\t" (1
|
|
||||||
# tab).
|
|
||||||
indent-string=' '
|
|
||||||
|
|
||||||
# Maximum number of characters on a single line. Pylint's default of 100 is
|
|
||||||
# based on PEP 8's guidance that teams may choose line lengths up to 99
|
|
||||||
# characters.
|
|
||||||
max-line-length=100
|
|
||||||
|
|
||||||
# Maximum number of lines in a module.
|
|
||||||
max-module-lines=1000
|
|
||||||
|
|
||||||
# Allow the body of a class to be on the same line as the declaration if body
|
|
||||||
# contains single statement.
|
|
||||||
single-line-class-stmt=no
|
|
||||||
|
|
||||||
# Allow the body of an if to be on the same line as the test if there is no
|
|
||||||
# else.
|
|
||||||
single-line-if-stmt=no
|
|
||||||
|
|
||||||
|
|
||||||
[IMPORTS]
|
|
||||||
|
|
||||||
# Force import order to recognize a module as part of a third party library.
|
|
||||||
known-third-party=enchant
|
|
||||||
|
|
||||||
|
|
||||||
[LOGGING]
|
|
||||||
|
|
||||||
# The type of string formatting that logging methods do. `old` means using %
|
|
||||||
# formatting, `new` is for `{}` formatting.
|
|
||||||
logging-format-style=old
|
|
||||||
|
|
||||||
# Logging modules to check that the string format arguments are in logging
|
|
||||||
# function parameter format.
|
|
||||||
logging-modules=logging
|
|
||||||
|
|
||||||
|
|
||||||
[MESSAGES CONTROL]
|
|
||||||
|
|
||||||
# Only show warnings with the listed confidence levels. Leave empty to show
|
|
||||||
# all. Valid levels: HIGH, CONTROL_FLOW, INFERENCE, INFERENCE_FAILURE,
|
|
||||||
# UNDEFINED.
|
|
||||||
confidence=HIGH,
|
|
||||||
CONTROL_FLOW,
|
|
||||||
INFERENCE,
|
|
||||||
INFERENCE_FAILURE,
|
|
||||||
UNDEFINED
|
|
||||||
|
|
||||||
# Disable the message, report, category or checker with the given id(s). You
|
|
||||||
# can either give multiple identifiers separated by comma (,) or put this
|
|
||||||
# option multiple times (only on the command line, not in the configuration
|
|
||||||
# file where it should appear only once). You can also use "--disable=all" to
|
|
||||||
# disable everything first and then re-enable specific checks. For example, if
|
|
||||||
# you want to run only the similarities checker, you can use "--disable=all
|
|
||||||
# --enable=similarities". If you want to run only the classes checker, but have
|
|
||||||
# no Warning level messages displayed, use "--disable=all --enable=classes
|
|
||||||
# --disable=W".
|
|
||||||
disable=raw-checker-failed,
|
|
||||||
bad-inline-option,
|
|
||||||
locally-disabled,
|
|
||||||
file-ignored,
|
|
||||||
suppressed-message,
|
|
||||||
useless-suppression,
|
|
||||||
deprecated-pragma,
|
|
||||||
use-symbolic-message-instead,
|
|
||||||
use-implicit-booleaness-not-comparison-to-string,
|
|
||||||
use-implicit-booleaness-not-comparison-to-zero
|
|
||||||
|
|
||||||
# Enable the message, report, category or checker with the given id(s). You can
|
|
||||||
# either give multiple identifier separated by comma (,) or put this option
|
|
||||||
# multiple time (only on the command line, not in the configuration file where
|
|
||||||
# it should appear only once). See also the "--disable" option for examples.
|
|
||||||
enable=
|
|
||||||
|
|
||||||
|
|
||||||
[METHOD_ARGS]
|
|
||||||
|
|
||||||
# List of qualified names (i.e., library.method) which require a timeout
|
|
||||||
# parameter e.g. 'requests.api.get,requests.api.post'
|
|
||||||
timeout-methods=requests.api.delete,requests.api.get,requests.api.head,requests.api.options,requests.api.patch,requests.api.post,requests.api.put,requests.api.request
|
|
||||||
|
|
||||||
|
|
||||||
[MISCELLANEOUS]
|
|
||||||
|
|
||||||
# Whether or not to search for fixme's in docstrings.
|
|
||||||
check-fixme-in-docstring=no
|
|
||||||
|
|
||||||
# List of note tags to take in consideration, separated by a comma.
|
|
||||||
notes=FIXME,
|
|
||||||
XXX,
|
|
||||||
TODO
|
|
||||||
|
|
||||||
# Regular expression of note tags to take in consideration.
|
|
||||||
notes-rgx=
|
|
||||||
|
|
||||||
|
|
||||||
[REFACTORING]
|
|
||||||
|
|
||||||
# Maximum number of nested blocks for function / method body
|
|
||||||
max-nested-blocks=5
|
|
||||||
|
|
||||||
# Complete name of functions that never returns. When checking for
|
|
||||||
# inconsistent-return-statements if a never returning function is called then
|
|
||||||
# it will be considered as an explicit return statement and no message will be
|
|
||||||
# printed.
|
|
||||||
never-returning-functions=sys.exit,argparse.parse_error
|
|
||||||
|
|
||||||
# Let 'consider-using-join' be raised when the separator to join on would be
|
|
||||||
# non-empty (resulting in expected fixes of the type: ``"- " + " -
|
|
||||||
# ".join(items)``)
|
|
||||||
suggest-join-with-non-empty-separator=yes
|
|
||||||
|
|
||||||
|
|
||||||
[REPORTS]
|
|
||||||
|
|
||||||
# Python expression which should return a score less than or equal to 10. You
|
|
||||||
# have access to the variables 'fatal', 'error', 'warning', 'refactor',
|
|
||||||
# 'convention', and 'info' which contain the number of messages in each
|
|
||||||
# category, as well as 'statement' which is the total number of statements
|
|
||||||
# analyzed. This score is used by the global evaluation report (RP0004).
|
|
||||||
evaluation=max(0, 0 if fatal else 10.0 - ((float(5 * error + warning + refactor + convention) / statement) * 10))
|
|
||||||
|
|
||||||
# Template used to display messages. This is a python new-style format string
|
|
||||||
# used to format the message information. See doc for all details.
|
|
||||||
msg-template=
|
|
||||||
|
|
||||||
# Set the output format. Available formats are: 'text', 'parseable',
|
|
||||||
# 'colorized', 'json2' (improved json format), 'json' (old json format), msvs
|
|
||||||
# (visual studio) and 'github' (GitHub actions). You can also give a reporter
|
|
||||||
# class, e.g. mypackage.mymodule.MyReporterClass.
|
|
||||||
#output-format=
|
|
||||||
|
|
||||||
# Tells whether to display a full report or only the messages.
|
|
||||||
reports=no
|
|
||||||
|
|
||||||
# Activate the evaluation score.
|
|
||||||
score=yes
|
|
||||||
|
|
||||||
|
|
||||||
[SIMILARITIES]
|
|
||||||
|
|
||||||
# Comments are removed from the similarity computation
|
|
||||||
ignore-comments=yes
|
|
||||||
|
|
||||||
# Docstrings are removed from the similarity computation
|
|
||||||
ignore-docstrings=yes
|
|
||||||
|
|
||||||
# Imports are removed from the similarity computation
|
|
||||||
ignore-imports=yes
|
|
||||||
|
|
||||||
# Signatures are removed from the similarity computation
|
|
||||||
ignore-signatures=yes
|
|
||||||
|
|
||||||
# Minimum lines number of a similarity.
|
|
||||||
min-similarity-lines=4
|
|
||||||
|
|
||||||
|
|
||||||
[SPELLING]
|
|
||||||
|
|
||||||
# Limits count of emitted suggestions for spelling mistakes.
|
|
||||||
max-spelling-suggestions=4
|
|
||||||
|
|
||||||
# Spelling dictionary name. No available dictionaries : You need to install
|
|
||||||
# both the python package and the system dependency for enchant to work.
|
|
||||||
spelling-dict=
|
|
||||||
|
|
||||||
# List of comma separated words that should be considered directives if they
|
|
||||||
# appear at the beginning of a comment and should not be checked.
|
|
||||||
spelling-ignore-comment-directives=fmt: on,fmt: off,noqa:,noqa,nosec,isort:skip,mypy:
|
|
||||||
|
|
||||||
# List of comma separated words that should not be checked.
|
|
||||||
spelling-ignore-words=
|
|
||||||
|
|
||||||
# A path to a file that contains the private dictionary; one word per line.
|
|
||||||
spelling-private-dict-file=
|
|
||||||
|
|
||||||
# Tells whether to store unknown words to the private dictionary (see the
|
|
||||||
# --spelling-private-dict-file option) instead of raising a message.
|
|
||||||
spelling-store-unknown-words=no
|
|
||||||
|
|
||||||
|
|
||||||
[STRING]
|
|
||||||
|
|
||||||
# This flag controls whether inconsistent-quotes generates a warning when the
|
|
||||||
# character used as a quote delimiter is used inconsistently within a module.
|
|
||||||
check-quote-consistency=no
|
|
||||||
|
|
||||||
# This flag controls whether the implicit-str-concat should generate a warning
|
|
||||||
# on implicit string concatenation in sequences defined over several lines.
|
|
||||||
check-str-concat-over-line-jumps=no
|
|
||||||
|
|
||||||
|
|
||||||
[TYPECHECK]
|
|
||||||
|
|
||||||
# List of decorators that produce context managers, such as
|
|
||||||
# contextlib.contextmanager. Add to this list to register other decorators that
|
|
||||||
# produce valid context managers.
|
|
||||||
contextmanager-decorators=contextlib.contextmanager
|
|
||||||
|
|
||||||
# List of members which are set dynamically and missed by pylint inference
|
|
||||||
# system, and so shouldn't trigger E1101 when accessed. Python regular
|
|
||||||
# expressions are accepted.
|
|
||||||
generated-members=
|
|
||||||
|
|
||||||
# Tells whether to warn about missing members when the owner of the attribute
|
|
||||||
# is inferred to be None.
|
|
||||||
ignore-none=yes
|
|
||||||
|
|
||||||
# This flag controls whether pylint should warn about no-member and similar
|
|
||||||
# checks whenever an opaque object is returned when inferring. The inference
|
|
||||||
# can return multiple potential results while evaluating a Python object, but
|
|
||||||
# some branches might not be evaluated, which results in partial inference. In
|
|
||||||
# that case, it might be useful to still emit no-member and other checks for
|
|
||||||
# the rest of the inferred objects.
|
|
||||||
ignore-on-opaque-inference=yes
|
|
||||||
|
|
||||||
# List of symbolic message names to ignore for Mixin members.
|
|
||||||
ignored-checks-for-mixins=no-member,
|
|
||||||
not-async-context-manager,
|
|
||||||
not-context-manager,
|
|
||||||
attribute-defined-outside-init
|
|
||||||
|
|
||||||
# List of class names for which member attributes should not be checked (useful
|
|
||||||
# for classes with dynamically set attributes). This supports the use of
|
|
||||||
# qualified names.
|
|
||||||
ignored-classes=optparse.Values,thread._local,_thread._local,argparse.Namespace
|
|
||||||
|
|
||||||
# Show a hint with possible names when a member name was not found. The aspect
|
|
||||||
# of finding the hint is based on edit distance.
|
|
||||||
missing-member-hint=yes
|
|
||||||
|
|
||||||
# The maximum edit distance a name should have in order to be considered a
|
|
||||||
# similar match for a missing member name.
|
|
||||||
missing-member-hint-distance=1
|
|
||||||
|
|
||||||
# The total number of similar names that should be taken in consideration when
|
|
||||||
# showing a hint for a missing member.
|
|
||||||
missing-member-max-choices=1
|
|
||||||
|
|
||||||
# Regex pattern to define which classes are considered mixins.
|
|
||||||
mixin-class-rgx=.*[Mm]ixin
|
|
||||||
|
|
||||||
# List of decorators that change the signature of a decorated function.
|
|
||||||
signature-mutators=
|
|
||||||
|
|
||||||
|
|
||||||
[VARIABLES]
|
|
||||||
|
|
||||||
# List of additional names supposed to be defined in builtins. Remember that
|
|
||||||
# you should avoid defining new builtins when possible.
|
|
||||||
additional-builtins=
|
|
||||||
|
|
||||||
# Tells whether unused global variables should be treated as a violation.
|
|
||||||
allow-global-unused-variables=yes
|
|
||||||
|
|
||||||
# List of names allowed to shadow builtins
|
|
||||||
allowed-redefined-builtins=
|
|
||||||
|
|
||||||
# List of strings which can identify a callback function by name. A callback
|
|
||||||
# name must start or end with one of those strings.
|
|
||||||
callbacks=cb_,
|
|
||||||
_cb
|
|
||||||
|
|
||||||
# A regular expression matching the name of dummy variables (i.e. expected to
|
|
||||||
# not be used).
|
|
||||||
dummy-variables-rgx=_+$|(_[a-zA-Z0-9_]*[a-zA-Z0-9]+?$)|dummy|^ignored_|^unused_
|
|
||||||
|
|
||||||
# Argument names that match this expression will be ignored.
|
|
||||||
ignored-argument-names=_.*|^ignored_|^unused_
|
|
||||||
|
|
||||||
# Tells whether we should check for unused import in __init__ files.
|
|
||||||
init-import=no
|
|
||||||
|
|
||||||
# List of qualified module names which can have objects that can redefine
|
|
||||||
# builtins.
|
|
||||||
redefining-builtins-modules=six.moves,past.builtins,future.builtins,builtins,io
|
|
||||||
@@ -30,6 +30,7 @@ semantics open question.
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
import shutil
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -38,6 +39,7 @@ from ...log import info, warn
|
|||||||
from .bottle_state import (
|
from .bottle_state import (
|
||||||
mark_preserved,
|
mark_preserved,
|
||||||
per_bottle_dockerfile,
|
per_bottle_dockerfile,
|
||||||
|
per_bottle_dockerfile_path,
|
||||||
transcript_snapshot_dir,
|
transcript_snapshot_dir,
|
||||||
write_per_bottle_dockerfile,
|
write_per_bottle_dockerfile,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -71,11 +71,11 @@ from .git_gate import (
|
|||||||
GIT_GATE_ENTRYPOINT_IN_CONTAINER,
|
GIT_GATE_ENTRYPOINT_IN_CONTAINER,
|
||||||
GIT_GATE_HOOK_IN_CONTAINER,
|
GIT_GATE_HOOK_IN_CONTAINER,
|
||||||
)
|
)
|
||||||
from ...pipelock import (
|
from .pipelock import (
|
||||||
PIPELOCK_CA_CERT_IN_CONTAINER,
|
PIPELOCK_CA_CERT_IN_CONTAINER,
|
||||||
PIPELOCK_CA_KEY_IN_CONTAINER,
|
PIPELOCK_CA_KEY_IN_CONTAINER,
|
||||||
|
PIPELOCK_PORT,
|
||||||
)
|
)
|
||||||
from .pipelock import PIPELOCK_PORT
|
|
||||||
from .sidecar_bundle import (
|
from .sidecar_bundle import (
|
||||||
SIDECAR_BUNDLE_DOCKERFILE,
|
SIDECAR_BUNDLE_DOCKERFILE,
|
||||||
SIDECAR_BUNDLE_IMAGE,
|
SIDECAR_BUNDLE_IMAGE,
|
||||||
|
|||||||
@@ -15,23 +15,30 @@ import subprocess
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from ...log import die
|
from ...log import die
|
||||||
|
# Re-exported for the compose renderer + smolmachines launch step
|
||||||
|
# (they used to import these from this module before they moved to
|
||||||
|
# the platform-neutral pipelock module).
|
||||||
|
from ...pipelock import ( # noqa: F401
|
||||||
|
PIPELOCK_CA_CERT_IN_CONTAINER,
|
||||||
|
PIPELOCK_CA_KEY_IN_CONTAINER,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# Pipelock image, pinned by digest. The digest is the multi-arch image
|
# Pipelock image, pinned by digest. The digest is the multi-arch image
|
||||||
# index for ghcr.io/luckypipewrench/pipelock:2.3.0.
|
# index for ghcr.io/luckypipewrench/pipelock:2.3.0.
|
||||||
PIPELOCK_IMAGE = os.environ.get(
|
PIPELOCK_IMAGE = os.environ.get(
|
||||||
"BOT_BOTTLE_PIPELOCK_IMAGE",
|
"BOT_BOTTLE_PIPELOCK_IMAGE",
|
||||||
"ghcr.io/luckypipewrench/pipelock@sha256:"
|
"ghcr.io/luckypipewrench/pipelock@sha256:3b1a39417b98406ddc5dc2d8fcb42865ddc0c68a43d355db55f0f8cb06bc6de9",
|
||||||
"3b1a39417b98406ddc5dc2d8fcb42865ddc0c68a43d355db55f0f8cb06bc6de9",
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Listening port for pipelock's forward proxy.
|
# Listening port for pipelock's forward proxy.
|
||||||
PIPELOCK_PORT = os.environ.get("BOT_BOTTLE_PIPELOCK_PORT", "8888")
|
PIPELOCK_PORT = os.environ.get("BOT_BOTTLE_PIPELOCK_PORT", "8888")
|
||||||
|
|
||||||
|
|
||||||
# The URL egress dials for its upstream HTTPS_PROXY. egress and pipelock
|
# The URL egress dials for its upstream HTTPS_PROXY. egress and
|
||||||
# share the same container's network namespace inside the sidecar bundle, so
|
# pipelock share the same container's network namespace inside the
|
||||||
# loopback reaches pipelock directly — no docker DNS aliases involved.
|
# sidecar bundle, so loopback reaches pipelock directly — no docker
|
||||||
|
# DNS aliases involved.
|
||||||
BUNDLE_LOCAL_PIPELOCK_URL = f"http://127.0.0.1:{PIPELOCK_PORT}"
|
BUNDLE_LOCAL_PIPELOCK_URL = f"http://127.0.0.1:{PIPELOCK_PORT}"
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -61,10 +61,7 @@ REGISTRY_IMAGE = os.environ.get(
|
|||||||
# narrow.
|
# narrow.
|
||||||
CRANE_IMAGE = os.environ.get(
|
CRANE_IMAGE = os.environ.get(
|
||||||
"BOT_BOTTLE_CRANE_IMAGE",
|
"BOT_BOTTLE_CRANE_IMAGE",
|
||||||
(
|
"gcr.io/go-containerregistry/crane@sha256:0ae17ecb34315aa7cbff28f6eddee3b7adae0b2f90101260d990804db1eb0084",
|
||||||
"gcr.io/go-containerregistry/crane@sha256:"
|
|
||||||
"0ae17ecb34315aa7cbff28f6eddee3b7adae0b2f90101260d990804db1eb0084"
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import fcntl
|
import fcntl
|
||||||
import json
|
import json
|
||||||
|
import os
|
||||||
import platform
|
import platform
|
||||||
import re
|
import re
|
||||||
import sqlite3
|
import sqlite3
|
||||||
|
|||||||
@@ -41,18 +41,9 @@ def usage() -> None:
|
|||||||
sys.stderr.write(" info print env, skills, and prompt details for a named agent\n")
|
sys.stderr.write(" info print env, skills, and prompt details for a named agent\n")
|
||||||
sys.stderr.write(" init interactively create a new agent and add it to bot-bottle.json\n")
|
sys.stderr.write(" init interactively create a new agent and add it to bot-bottle.json\n")
|
||||||
sys.stderr.write(" list list available agents or active containers\n")
|
sys.stderr.write(" list list available agents or active containers\n")
|
||||||
sys.stderr.write(
|
sys.stderr.write(" resume re-launch a bottle by its identity (continues state from PRD 0016)\n")
|
||||||
" resume re-launch a bottle by its identity "
|
sys.stderr.write(" start boot a container for a named agent and attach an interactive session\n")
|
||||||
"(continues state from PRD 0016)\n"
|
sys.stderr.write(" supervise view + approve/modify/reject pending supervise proposals (PRD 0013)\n\n")
|
||||||
)
|
|
||||||
sys.stderr.write(
|
|
||||||
" start boot a container for a named agent and "
|
|
||||||
"attach an interactive session\n"
|
|
||||||
)
|
|
||||||
sys.stderr.write(
|
|
||||||
" supervise view + approve/modify/reject pending supervise "
|
|
||||||
"proposals (PRD 0013)\n\n"
|
|
||||||
)
|
|
||||||
sys.stderr.write(f"Run '{PROG} <command> --help' for command-specific usage.\n")
|
sys.stderr.write(f"Run '{PROG} <command> --help' for command-specific usage.\n")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+5
-18
@@ -51,8 +51,7 @@ def cmd_init(argv: list[str]) -> int:
|
|||||||
die(f"{target_file} exists but is not valid JSON; fix or remove it first")
|
die(f"{target_file} exists but is not valid JSON; fix or remove it first")
|
||||||
if agent_name in (existing.get("agents") or {}):
|
if agent_name in (existing.get("agents") or {}):
|
||||||
sys.stderr.write(
|
sys.stderr.write(
|
||||||
f'bot-bottle: agent "{agent_name}" already exists in '
|
f'bot-bottle: agent "{agent_name}" already exists in {target_file}. Overwrite? [y/N] '
|
||||||
f'{target_file}. Overwrite? [y/N] '
|
|
||||||
)
|
)
|
||||||
sys.stderr.flush()
|
sys.stderr.flush()
|
||||||
ow = read_tty_line()
|
ow = read_tty_line()
|
||||||
@@ -72,10 +71,7 @@ def cmd_init(argv: list[str]) -> int:
|
|||||||
|
|
||||||
# Prompt
|
# Prompt
|
||||||
print(file=sys.stderr)
|
print(file=sys.stderr)
|
||||||
info(
|
info("System prompt — enter text, then a lone '.' on its own line to finish (just '.' to leave empty):")
|
||||||
"System prompt — enter text, then a lone '.' on its own line to "
|
|
||||||
"finish (just '.' to leave empty):"
|
|
||||||
)
|
|
||||||
prompt_lines: list[str] = []
|
prompt_lines: list[str] = []
|
||||||
while True:
|
while True:
|
||||||
line = read_tty_line()
|
line = read_tty_line()
|
||||||
@@ -103,10 +99,7 @@ def cmd_init(argv: list[str]) -> int:
|
|||||||
|
|
||||||
if bottle_name in (existing.get("bottles") or {}):
|
if bottle_name in (existing.get("bottles") or {}):
|
||||||
bottle_exists_already = True
|
bottle_exists_already = True
|
||||||
info(
|
info(f"Bottle '{bottle_name}' already exists in {target_file}; agent will reference it.")
|
||||||
f"Bottle '{bottle_name}' already exists in {target_file}; "
|
|
||||||
f"agent will reference it."
|
|
||||||
)
|
|
||||||
else:
|
else:
|
||||||
info(f"Creating new bottle '{bottle_name}'.")
|
info(f"Creating new bottle '{bottle_name}'.")
|
||||||
bottle_env = _prompt_for_env_vars()
|
bottle_env = _prompt_for_env_vars()
|
||||||
@@ -138,14 +131,8 @@ def cmd_init(argv: list[str]) -> int:
|
|||||||
|
|
||||||
def _prompt_for_env_vars() -> dict[str, str]:
|
def _prompt_for_env_vars() -> dict[str, str]:
|
||||||
print(file=sys.stderr)
|
print(file=sys.stderr)
|
||||||
info(
|
info("Env vars — enter each var name then its mode. Press Enter with no name to finish.")
|
||||||
"Env vars — enter each var name then its mode. Press Enter with "
|
info(" Modes: secret (prompt at runtime) | interpolated (read from host env) | literal (hardcoded value)")
|
||||||
"no name to finish."
|
|
||||||
)
|
|
||||||
info(
|
|
||||||
" Modes: secret (prompt at runtime) | interpolated (read from "
|
|
||||||
"host env) | literal (hardcoded value)"
|
|
||||||
)
|
|
||||||
out: dict[str, str] = {}
|
out: dict[str, str] = {}
|
||||||
while True:
|
while True:
|
||||||
print(file=sys.stderr)
|
print(file=sys.stderr)
|
||||||
|
|||||||
+3
-28
@@ -33,7 +33,6 @@ from ..backend.docker.capability_apply import snapshot_transcript
|
|||||||
from ..log import info
|
from ..log import info
|
||||||
from ..manifest import Manifest
|
from ..manifest import Manifest
|
||||||
from ._common import PROG, USER_CWD, read_tty_line
|
from ._common import PROG, USER_CWD, read_tty_line
|
||||||
from . import tui
|
|
||||||
|
|
||||||
|
|
||||||
def cmd_start(argv: list[str]) -> int:
|
def cmd_start(argv: list[str]) -> int:
|
||||||
@@ -50,39 +49,15 @@ def cmd_start(argv: list[str]) -> int:
|
|||||||
"or 'docker'). Overrides the env var when set."
|
"or 'docker'). Overrides the env var when set."
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
parser.add_argument(
|
parser.add_argument("name", help="agent name defined in bot-bottle.json")
|
||||||
"name",
|
|
||||||
nargs="?",
|
|
||||||
default=None,
|
|
||||||
help="agent name defined in bot-bottle.json (omit to pick interactively)",
|
|
||||||
)
|
|
||||||
args = parser.parse_args(argv)
|
args = parser.parse_args(argv)
|
||||||
|
|
||||||
dry_run = args.dry_run or os.environ.get("BOT_BOTTLE_DRY_RUN") == "1"
|
dry_run = args.dry_run or os.environ.get("BOT_BOTTLE_DRY_RUN") == "1"
|
||||||
|
|
||||||
manifest = Manifest.resolve(USER_CWD)
|
manifest = Manifest.resolve(USER_CWD)
|
||||||
|
|
||||||
agent_name: str | None = args.name
|
|
||||||
if agent_name is None:
|
|
||||||
agent_name = tui.filter_select(
|
|
||||||
sorted(manifest.agents.keys()),
|
|
||||||
title="Select agent",
|
|
||||||
)
|
|
||||||
if agent_name is None:
|
|
||||||
return 0
|
|
||||||
|
|
||||||
backend_name: str | None = args.backend
|
|
||||||
if backend_name is None and "BOT_BOTTLE_BACKEND" not in os.environ:
|
|
||||||
backend_name = tui.filter_select(
|
|
||||||
list(known_backend_names()),
|
|
||||||
title="Select backend",
|
|
||||||
)
|
|
||||||
if backend_name is None:
|
|
||||||
return 0
|
|
||||||
|
|
||||||
spec = BottleSpec(
|
spec = BottleSpec(
|
||||||
manifest=manifest,
|
manifest=manifest,
|
||||||
agent_name=agent_name,
|
agent_name=args.name,
|
||||||
copy_cwd=args.cwd,
|
copy_cwd=args.cwd,
|
||||||
user_cwd=USER_CWD,
|
user_cwd=USER_CWD,
|
||||||
)
|
)
|
||||||
@@ -90,7 +65,7 @@ def cmd_start(argv: list[str]) -> int:
|
|||||||
spec,
|
spec,
|
||||||
dry_run=dry_run,
|
dry_run=dry_run,
|
||||||
remote_control=args.remote_control,
|
remote_control=args.remote_control,
|
||||||
backend_name=backend_name,
|
backend_name=args.backend,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,219 +0,0 @@
|
|||||||
"""tui.py — minimal curses filter-select picker for CLI prompts.
|
|
||||||
|
|
||||||
Exposed surface:
|
|
||||||
|
|
||||||
filter_select(items, *, title="", tty_path="/dev/tty") -> str | None
|
|
||||||
|
|
||||||
Opens /dev/tty directly so the picker works even when stdout/stdin are
|
|
||||||
redirected. Returns the selected item or None on cancel.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import curses
|
|
||||||
import os
|
|
||||||
import sys
|
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
|
|
||||||
def filter_select(
|
|
||||||
items: list[str],
|
|
||||||
*,
|
|
||||||
title: str = "",
|
|
||||||
tty_path: str = "/dev/tty",
|
|
||||||
) -> Optional[str]:
|
|
||||||
"""Render a filter-select picker over *items*.
|
|
||||||
|
|
||||||
Returns the selected item string, or ``None`` if the user cancelled
|
|
||||||
(Esc / ``q`` / Ctrl-C / Ctrl-D) or if the terminal is too small.
|
|
||||||
|
|
||||||
The picker opens *tty_path* directly so it works even when
|
|
||||||
stdout/stdin are redirected.
|
|
||||||
"""
|
|
||||||
if not items:
|
|
||||||
return None
|
|
||||||
|
|
||||||
try:
|
|
||||||
tty_fd = open(tty_path, "r+b", buffering=0)
|
|
||||||
except OSError:
|
|
||||||
return None
|
|
||||||
|
|
||||||
try:
|
|
||||||
result = _run_picker(items, title=title, tty_fd=tty_fd)
|
|
||||||
finally:
|
|
||||||
tty_fd.close()
|
|
||||||
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Internal implementation
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
_KEY_ESC = 27
|
|
||||||
_KEY_CTRL_C = 3
|
|
||||||
_KEY_CTRL_D = 4
|
|
||||||
_KEY_BACKSPACE_WIN = 8
|
|
||||||
_KEY_ENTER_ALT = 10
|
|
||||||
|
|
||||||
_CANCEL_KEYS = frozenset([_KEY_ESC, _KEY_CTRL_C, _KEY_CTRL_D, ord("q")])
|
|
||||||
|
|
||||||
|
|
||||||
def _run_picker(items: list[str], *, title: str, tty_fd) -> Optional[str]:
|
|
||||||
"""Drive a curses session on *tty_fd* and return the picked item."""
|
|
||||||
# newterm lets us run curses on an arbitrary fd rather than the
|
|
||||||
# process's controlling tty / stdout — crucial when stdout is piped.
|
|
||||||
old_term = os.environ.get("TERM", "xterm-256color")
|
|
||||||
os.environ.setdefault("TERM", "xterm-256color")
|
|
||||||
|
|
||||||
# Save / restore the real stdin/stdout so curses newterm can use tty_fd.
|
|
||||||
orig_stdin = sys.__stdin__
|
|
||||||
orig_stdout = sys.__stdout__
|
|
||||||
|
|
||||||
try:
|
|
||||||
import io
|
|
||||||
tty_text = io.TextIOWrapper(tty_fd, write_through=True)
|
|
||||||
sys.__stdin__ = tty_text # type: ignore[assignment]
|
|
||||||
sys.__stdout__ = tty_text # type: ignore[assignment]
|
|
||||||
|
|
||||||
# curses.wrapper calls initscr which honours sys.__stdin__ / __stdout__
|
|
||||||
# on some builds; use newterm where available.
|
|
||||||
screen = curses.initscr()
|
|
||||||
curses.noecho()
|
|
||||||
curses.cbreak()
|
|
||||||
screen.keypad(True)
|
|
||||||
|
|
||||||
try:
|
|
||||||
result = _picker_loop(screen, items, title=title)
|
|
||||||
finally:
|
|
||||||
screen.keypad(False)
|
|
||||||
curses.nocbreak()
|
|
||||||
curses.echo()
|
|
||||||
curses.endwin()
|
|
||||||
except Exception:
|
|
||||||
return None
|
|
||||||
finally:
|
|
||||||
sys.__stdin__ = orig_stdin # type: ignore[assignment]
|
|
||||||
sys.__stdout__ = orig_stdout # type: ignore[assignment]
|
|
||||||
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
def _picker_loop(screen, items: list[str], *, title: str) -> Optional[str]:
|
|
||||||
query = ""
|
|
||||||
cursor = 0
|
|
||||||
|
|
||||||
while True:
|
|
||||||
filtered = _filter_items(items, query)
|
|
||||||
|
|
||||||
# Clamp cursor into the visible list.
|
|
||||||
if not filtered:
|
|
||||||
cursor = 0
|
|
||||||
elif cursor >= len(filtered):
|
|
||||||
cursor = len(filtered) - 1
|
|
||||||
|
|
||||||
try:
|
|
||||||
_render(screen, filtered, cursor, query=query, title=title)
|
|
||||||
except curses.error:
|
|
||||||
# Terminal too small or write error — bail out.
|
|
||||||
return None
|
|
||||||
|
|
||||||
try:
|
|
||||||
key = screen.getch()
|
|
||||||
except KeyboardInterrupt:
|
|
||||||
return None
|
|
||||||
|
|
||||||
if key in _CANCEL_KEYS:
|
|
||||||
return None
|
|
||||||
|
|
||||||
if key in (curses.KEY_ENTER, _KEY_ENTER_ALT, ord("\r")):
|
|
||||||
return filtered[cursor] if filtered else None
|
|
||||||
|
|
||||||
if key in (curses.KEY_UP, ord("k")):
|
|
||||||
if cursor > 0:
|
|
||||||
cursor -= 1
|
|
||||||
|
|
||||||
elif key in (curses.KEY_DOWN, ord("j")):
|
|
||||||
if cursor < len(filtered) - 1:
|
|
||||||
cursor += 1
|
|
||||||
|
|
||||||
elif key in (curses.KEY_BACKSPACE, _KEY_BACKSPACE_WIN, 127):
|
|
||||||
query = query[:-1]
|
|
||||||
# After narrowing the filter, keep cursor in range.
|
|
||||||
new_filtered = _filter_items(items, query)
|
|
||||||
if cursor >= len(new_filtered):
|
|
||||||
cursor = max(0, len(new_filtered) - 1)
|
|
||||||
|
|
||||||
elif 32 <= key <= 126:
|
|
||||||
# Printable ASCII — append to query and reset cursor so the
|
|
||||||
# top of the newly-filtered list is selected.
|
|
||||||
query += chr(key)
|
|
||||||
cursor = 0
|
|
||||||
|
|
||||||
|
|
||||||
def _filter_items(items: list[str], query: str) -> list[str]:
|
|
||||||
if not query:
|
|
||||||
return list(items)
|
|
||||||
q = query.lower()
|
|
||||||
return [i for i in items if q in i.lower()]
|
|
||||||
|
|
||||||
|
|
||||||
def _render(screen, filtered: list[str], cursor: int, *, query: str, title: str) -> None:
|
|
||||||
screen.erase()
|
|
||||||
rows, cols = screen.getmaxyx()
|
|
||||||
min_rows = 5
|
|
||||||
|
|
||||||
if rows < min_rows:
|
|
||||||
raise curses.error("terminal too small")
|
|
||||||
|
|
||||||
row = 0
|
|
||||||
|
|
||||||
if title and row < rows - 1:
|
|
||||||
_addstr_safe(screen, row, 0, title[:cols - 1], curses.A_BOLD)
|
|
||||||
row += 1
|
|
||||||
|
|
||||||
filter_label = f"Filter: {query}"
|
|
||||||
if row < rows - 1:
|
|
||||||
_addstr_safe(screen, row, 0, filter_label[:cols - 1])
|
|
||||||
row += 1
|
|
||||||
|
|
||||||
sep = "─" * min(cols - 1, 40)
|
|
||||||
if row < rows - 1:
|
|
||||||
_addstr_safe(screen, row, 0, sep)
|
|
||||||
row += 1
|
|
||||||
|
|
||||||
list_start = row
|
|
||||||
# Reserve two rows for separator + help line at bottom.
|
|
||||||
list_rows = rows - list_start - 2
|
|
||||||
if list_rows < 1:
|
|
||||||
return
|
|
||||||
|
|
||||||
# Scroll window: keep cursor visible.
|
|
||||||
scroll = max(0, cursor - list_rows + 1)
|
|
||||||
visible = filtered[scroll: scroll + list_rows]
|
|
||||||
|
|
||||||
for idx, item in enumerate(visible):
|
|
||||||
abs_idx = scroll + idx
|
|
||||||
attr = curses.A_REVERSE if abs_idx == cursor else curses.A_NORMAL
|
|
||||||
prefix = "> " if abs_idx == cursor else " "
|
|
||||||
line = (prefix + item)[:cols - 1]
|
|
||||||
if row < rows - 1:
|
|
||||||
_addstr_safe(screen, row, 0, line, attr)
|
|
||||||
row += 1
|
|
||||||
|
|
||||||
if row < rows - 1:
|
|
||||||
_addstr_safe(screen, row, 0, sep)
|
|
||||||
row += 1
|
|
||||||
|
|
||||||
help_line = "[↑↓/jk] move [Enter] select [Esc/q] cancel"
|
|
||||||
if row < rows:
|
|
||||||
_addstr_safe(screen, min(rows - 1, row), 0, help_line[:cols - 1])
|
|
||||||
|
|
||||||
screen.refresh()
|
|
||||||
|
|
||||||
|
|
||||||
def _addstr_safe(screen, row: int, col: int, text: str, attr: int = curses.A_NORMAL) -> None:
|
|
||||||
try:
|
|
||||||
screen.addstr(row, col, text, attr)
|
|
||||||
except curses.error:
|
|
||||||
pass
|
|
||||||
@@ -25,7 +25,7 @@ flow (PRD 0014) at egress and renames the MCP tool.
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import dataclasses
|
import dataclasses
|
||||||
from abc import ABC
|
from abc import ABC, abstractmethod
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|||||||
@@ -38,12 +38,7 @@ from mitmproxy import http # type: ignore[import-not-found]
|
|||||||
# Absolute import (NOT `from .egress_addon_core`) — the
|
# Absolute import (NOT `from .egress_addon_core`) — the
|
||||||
# container drops both files flat into /app/ so they are sibling
|
# container drops both files flat into /app/ so they are sibling
|
||||||
# top-level modules to mitmdump's loader, not a package.
|
# top-level modules to mitmdump's loader, not a package.
|
||||||
from egress_addon_core import ( # type: ignore[import-not-found]
|
from egress_addon_core import Route, decide, is_git_push_request, load_routes # type: ignore[import-not-found]
|
||||||
Route,
|
|
||||||
decide,
|
|
||||||
is_git_push_request,
|
|
||||||
load_routes,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
DEFAULT_ROUTES_PATH = "/etc/egress/routes.yaml"
|
DEFAULT_ROUTES_PATH = "/etc/egress/routes.yaml"
|
||||||
|
|||||||
@@ -78,13 +78,11 @@ def parse_routes(payload: object) -> tuple[Route, ...]:
|
|||||||
"""
|
"""
|
||||||
if not isinstance(payload, dict):
|
if not isinstance(payload, dict):
|
||||||
raise ValueError("routes payload: top-level must be an object")
|
raise ValueError("routes payload: top-level must be an object")
|
||||||
payload_dict: dict[str, object] = typing.cast(dict[str, object], payload)
|
raw = payload.get("routes")
|
||||||
raw: object = payload_dict.get("routes")
|
|
||||||
if not isinstance(raw, list):
|
if not isinstance(raw, list):
|
||||||
raise ValueError("routes payload: 'routes' must be a list")
|
raise ValueError("routes payload: 'routes' must be a list")
|
||||||
raw_list: list[object] = typing.cast(list[object], raw)
|
|
||||||
out: list[Route] = []
|
out: list[Route] = []
|
||||||
for i, r in enumerate(raw_list):
|
for i, r in enumerate(raw):
|
||||||
out.append(_parse_one(i, r))
|
out.append(_parse_one(i, r))
|
||||||
return tuple(out)
|
return tuple(out)
|
||||||
|
|
||||||
@@ -93,17 +91,15 @@ def _parse_one(idx: int, raw: object) -> Route:
|
|||||||
label = f"route[{idx}]"
|
label = f"route[{idx}]"
|
||||||
if not isinstance(raw, dict):
|
if not isinstance(raw, dict):
|
||||||
raise ValueError(f"{label}: must be an object (got {type(raw).__name__})")
|
raise ValueError(f"{label}: must be an object (got {type(raw).__name__})")
|
||||||
raw_dict: dict[str, object] = typing.cast(dict[str, object], raw)
|
host = raw.get("host")
|
||||||
host: object = raw_dict.get("host")
|
|
||||||
if not isinstance(host, str) or not host:
|
if not isinstance(host, str) or not host:
|
||||||
raise ValueError(f"{label}: 'host' must be a non-empty string")
|
raise ValueError(f"{label}: 'host' must be a non-empty string")
|
||||||
|
|
||||||
path_allow_raw: object = raw_dict.get("path_allowlist", [])
|
path_allow_raw = raw.get("path_allowlist", [])
|
||||||
if not isinstance(path_allow_raw, list):
|
if not isinstance(path_allow_raw, list):
|
||||||
raise ValueError(f"{label} ({host}): 'path_allowlist' must be a list")
|
raise ValueError(f"{label} ({host}): 'path_allowlist' must be a list")
|
||||||
path_allow_list: list[object] = typing.cast(list[object], path_allow_raw)
|
|
||||||
prefixes: list[str] = []
|
prefixes: list[str] = []
|
||||||
for j, p in enumerate(path_allow_list):
|
for j, p in enumerate(path_allow_raw):
|
||||||
if not isinstance(p, str):
|
if not isinstance(p, str):
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
f"{label} ({host}): path_allowlist[{j}] must be a string"
|
f"{label} ({host}): path_allowlist[{j}] must be a string"
|
||||||
@@ -115,8 +111,8 @@ def _parse_one(idx: int, raw: object) -> Route:
|
|||||||
)
|
)
|
||||||
prefixes.append(p)
|
prefixes.append(p)
|
||||||
|
|
||||||
auth_scheme: object = raw_dict.get("auth_scheme", "")
|
auth_scheme = raw.get("auth_scheme", "")
|
||||||
token_env: object = raw_dict.get("token_env", "")
|
token_env = raw.get("token_env", "")
|
||||||
if not isinstance(auth_scheme, str):
|
if not isinstance(auth_scheme, str):
|
||||||
raise ValueError(f"{label} ({host}): 'auth_scheme' must be a string")
|
raise ValueError(f"{label} ({host}): 'auth_scheme' must be a string")
|
||||||
if not isinstance(token_env, str):
|
if not isinstance(token_env, str):
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ from __future__ import annotations
|
|||||||
import dataclasses
|
import dataclasses
|
||||||
import os
|
import os
|
||||||
import shlex
|
import shlex
|
||||||
from abc import ABC
|
from abc import ABC, abstractmethod
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ from .manifest_egress import (
|
|||||||
EgressConfig,
|
EgressConfig,
|
||||||
EgressRoute,
|
EgressRoute,
|
||||||
PipelockRoutePolicy,
|
PipelockRoutePolicy,
|
||||||
|
validate_egress_routes,
|
||||||
)
|
)
|
||||||
from .manifest_git import GitEntry, GitUser, parse_git_gate_config
|
from .manifest_git import GitEntry, GitUser, parse_git_gate_config
|
||||||
from .manifest_schema import BOTTLE_KEYS
|
from .manifest_schema import BOTTLE_KEYS
|
||||||
@@ -322,11 +323,8 @@ class Manifest:
|
|||||||
return
|
return
|
||||||
available = ", ".join(self.agents.keys())
|
available = ", ".join(self.agents.keys())
|
||||||
if available:
|
if available:
|
||||||
msg = f"agent '{name}' not defined in bot-bottle.json. Available: {available}"
|
raise ManifestError(f"agent '{name}' not defined in bot-bottle.json. Available: {available}")
|
||||||
raise ManifestError(msg)
|
raise ManifestError(f"agent '{name}' not defined in bot-bottle.json (manifest is empty).")
|
||||||
raise ManifestError(
|
|
||||||
f"agent '{name}' not defined in bot-bottle.json (manifest is empty)."
|
|
||||||
)
|
|
||||||
|
|
||||||
def has_bottle(self, name: str) -> bool:
|
def has_bottle(self, name: str) -> bool:
|
||||||
return name in self.bottles
|
return name in self.bottles
|
||||||
|
|||||||
@@ -114,10 +114,7 @@ class Agent:
|
|||||||
|
|
||||||
bottle = d.get("bottle")
|
bottle = d.get("bottle")
|
||||||
if not isinstance(bottle, str) or not bottle:
|
if not isinstance(bottle, str) or not bottle:
|
||||||
raise ManifestError(
|
raise ManifestError(f"agent '{name}' must declare a 'bottle' field naming a defined bottle")
|
||||||
f"agent '{name}' must declare a 'bottle' field naming a "
|
|
||||||
f"defined bottle"
|
|
||||||
)
|
|
||||||
if bottle not in bottle_names:
|
if bottle not in bottle_names:
|
||||||
available = ", ".join(sorted(bottle_names)) or "(none defined)"
|
available = ", ".join(sorted(bottle_names)) or "(none defined)"
|
||||||
raise ManifestError(
|
raise ManifestError(
|
||||||
@@ -129,10 +126,7 @@ class Agent:
|
|||||||
skills_raw = d.get("skills")
|
skills_raw = d.get("skills")
|
||||||
if skills_raw is not None:
|
if skills_raw is not None:
|
||||||
if not isinstance(skills_raw, list):
|
if not isinstance(skills_raw, list):
|
||||||
raise ManifestError(
|
raise ManifestError(f"agent '{name}' skills must be an array (was {type(skills_raw).__name__})")
|
||||||
f"agent '{name}' skills must be an array "
|
|
||||||
f"(was {type(skills_raw).__name__})"
|
|
||||||
)
|
|
||||||
collected: list[str] = []
|
collected: list[str] = []
|
||||||
skills_list = cast(list[object], skills_raw)
|
skills_list = cast(list[object], skills_raw)
|
||||||
for i, skill in enumerate(skills_list):
|
for i, skill in enumerate(skills_list):
|
||||||
@@ -150,10 +144,7 @@ class Agent:
|
|||||||
elif isinstance(prompt_raw, str):
|
elif isinstance(prompt_raw, str):
|
||||||
prompt = prompt_raw
|
prompt = prompt_raw
|
||||||
else:
|
else:
|
||||||
raise ManifestError(
|
raise ManifestError(f"agent '{name}' prompt must be a string (was {type(prompt_raw).__name__})")
|
||||||
f"agent '{name}' prompt must be a string "
|
|
||||||
f"(was {type(prompt_raw).__name__})"
|
|
||||||
)
|
|
||||||
|
|
||||||
# git-gate: agents may declare only `git-gate.user` (name/email).
|
# git-gate: agents may declare only `git-gate.user` (name/email).
|
||||||
# `git-gate.repos` is bottle-only — it carries credentials and host trust.
|
# `git-gate.repos` is bottle-only — it carries credentials and host trust.
|
||||||
|
|||||||
@@ -214,8 +214,7 @@ class EgressRoute:
|
|||||||
collected_roles: list[str] = []
|
collected_roles: list[str] = []
|
||||||
for r in role_list:
|
for r in role_list:
|
||||||
if not isinstance(r, str):
|
if not isinstance(r, str):
|
||||||
msg = f"{label} role items must be strings (got {type(r).__name__})"
|
raise ManifestError(f"{label} role items must be strings (got {type(r).__name__})")
|
||||||
raise ManifestError(msg)
|
|
||||||
collected_roles.append(r)
|
collected_roles.append(r)
|
||||||
roles = tuple(collected_roles)
|
roles = tuple(collected_roles)
|
||||||
else:
|
else:
|
||||||
|
|||||||
@@ -30,18 +30,12 @@ def parse_git_upstream(url: str, label: str) -> tuple[str, str, str, str]:
|
|||||||
raise ManifestError(f"{label} must be an ssh:// URL (was {url!r})")
|
raise ManifestError(f"{label} must be an ssh:// URL (was {url!r})")
|
||||||
rest = url[len("ssh://"):]
|
rest = url[len("ssh://"):]
|
||||||
if "@" not in rest:
|
if "@" not in rest:
|
||||||
raise ManifestError(
|
raise ManifestError(f"{label} must include a user (e.g. ssh://git@host/path.git); was {url!r}")
|
||||||
f"{label} must include a user (e.g. ssh://git@host/path.git); "
|
|
||||||
f"was {url!r}"
|
|
||||||
)
|
|
||||||
user, _, hostpart = rest.partition("@")
|
user, _, hostpart = rest.partition("@")
|
||||||
if not user:
|
if not user:
|
||||||
raise ManifestError(f"{label} user is empty in {url!r}")
|
raise ManifestError(f"{label} user is empty in {url!r}")
|
||||||
if "/" not in hostpart:
|
if "/" not in hostpart:
|
||||||
raise ManifestError(
|
raise ManifestError(f"{label} must include a path (e.g. ssh://git@host/path.git); was {url!r}")
|
||||||
f"{label} must include a path (e.g. ssh://git@host/path.git); "
|
|
||||||
f"was {url!r}"
|
|
||||||
)
|
|
||||||
hostport, _, path = hostpart.partition("/")
|
hostport, _, path = hostpart.partition("/")
|
||||||
if not path:
|
if not path:
|
||||||
raise ManifestError(f"{label} path is empty in {url!r}")
|
raise ManifestError(f"{label} path is empty in {url!r}")
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ from __future__ import annotations
|
|||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from .egress import EgressRoute, egress_routes_for_bottle
|
from .egress import EGRESS_HOSTNAME, EgressRoute, egress_routes_for_bottle
|
||||||
from .supervise import SUPERVISE_HOSTNAME
|
from .supervise import SUPERVISE_HOSTNAME
|
||||||
from .manifest import Bottle
|
from .manifest import Bottle
|
||||||
|
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ import json
|
|||||||
import os
|
import os
|
||||||
import time
|
import time
|
||||||
import uuid
|
import uuid
|
||||||
from abc import ABC
|
from abc import ABC, abstractmethod
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|||||||
@@ -159,10 +159,7 @@ TOOL_DEFINITIONS: list[dict[str, object]] = [
|
|||||||
"properties": {
|
"properties": {
|
||||||
"host": {
|
"host": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": (
|
"description": "The hostname to allow (e.g. 'api.github.com'). Case-insensitive on match.",
|
||||||
"The hostname to allow (e.g. 'api.github.com'). "
|
|
||||||
"Case-insensitive on match."
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
"path_allowlist": {
|
"path_allowlist": {
|
||||||
"type": "array",
|
"type": "array",
|
||||||
|
|||||||
@@ -0,0 +1,283 @@
|
|||||||
|
# PRD 0049: Named / Labelled Agents
|
||||||
|
|
||||||
|
- **Status:** Draft
|
||||||
|
- **Author:** didericis
|
||||||
|
- **Created:** 2026-06-03
|
||||||
|
- **Issue:** #171
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
At agent launch time, prompt the operator for a short human-readable label
|
||||||
|
(defaulting to the manifest agent key) and an optional color from the 16-color
|
||||||
|
ANSI palette. Store both in the bottle's `metadata.json`. Display the label —
|
||||||
|
rendered in the chosen color — in the dashboard's active-agents pane, replacing
|
||||||
|
the bare manifest key. Inject the label and color into the in-container
|
||||||
|
`claude.json` as `name` / `color` so Claude Code can surface them in its own
|
||||||
|
harness when upstream support lands.
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
The dashboard's agents pane identifies each running instance by its manifest
|
||||||
|
agent key (e.g., `implementer`) plus a random slug suffix. When an operator
|
||||||
|
runs three `implementer` bottles simultaneously — one each for three different
|
||||||
|
repos — the pane shows:
|
||||||
|
|
||||||
|
```
|
||||||
|
[docker] a3f9 implementer started 14:02:11 [egress,pipelock]
|
||||||
|
[docker] b81c implementer started 14:03:45 [egress,pipelock]
|
||||||
|
[docker] d220 implementer started 14:05:01 [egress,pipelock]
|
||||||
|
```
|
||||||
|
|
||||||
|
There is no way to tell which bottle is working on which task without attaching
|
||||||
|
to each one in turn. The slug is opaque; the manifest key is shared. Operators
|
||||||
|
working a multi-bottle session resort to keeping a mental map of slug→task,
|
||||||
|
which breaks the moment they switch windows.
|
||||||
|
|
||||||
|
## Goals / Success Criteria
|
||||||
|
|
||||||
|
1. After the operator selects an agent name (dashboard picker or CLI argument),
|
||||||
|
they are prompted for a label. The prompt suggests the manifest key as the
|
||||||
|
default; pressing Enter (or providing no input) accepts it. The label may
|
||||||
|
contain any printable characters up to 64 bytes.
|
||||||
|
2. After the label prompt, the operator is optionally prompted for a color from
|
||||||
|
the 16-color ANSI palette (names: `black`, `red`, `green`, `yellow`, `blue`,
|
||||||
|
`magenta`, `cyan`, `white`, `bright-black`, `bright-red`, `bright-green`,
|
||||||
|
`bright-yellow`, `bright-blue`, `bright-magenta`, `bright-cyan`,
|
||||||
|
`bright-white`). Pressing Enter without a selection skips color entirely.
|
||||||
|
3. `label` and `color` are stored in `BottleMetadata` and written to the
|
||||||
|
bottle's `metadata.json`. Both fields default to `""` (empty / unset).
|
||||||
|
4. `ActiveAgent` carries `label` and `color`; `enumerate_active()` reads them
|
||||||
|
from `metadata.json`.
|
||||||
|
5. `_format_agent_row` uses the label when non-empty (falling back to
|
||||||
|
`agent_name`). If a non-empty color is set and the terminal supports it, the
|
||||||
|
label substring is rendered in that color.
|
||||||
|
6. `BottleSpec` carries `label` and `color`; the docker backend's `prepare`
|
||||||
|
step copies them into `BottleMetadata`.
|
||||||
|
7. `agent_provider.py` writes `label` → `"name"` and `color` → `"color"` into
|
||||||
|
the generated `claude.json`, alongside the existing fields. Fields are
|
||||||
|
omitted when empty.
|
||||||
|
8. The dashboard's `_new_agent_flow` (PRD 0020) includes the label+color step
|
||||||
|
between agent selection and the backend picker.
|
||||||
|
9. `cmd_start` (CLI) includes the label+color step after argument validation
|
||||||
|
and before prepare-with-preflight.
|
||||||
|
10. All existing unit tests stay green; no new tests are required for this
|
||||||
|
change (the label/color fields are thin plumbing with no branching logic
|
||||||
|
worth unit-testing beyond the already-tested metadata read/write path).
|
||||||
|
|
||||||
|
## Non-goals
|
||||||
|
|
||||||
|
- Showing the agent label inside the Claude Code TUI (status line, terminal
|
||||||
|
title, custom header). That requires upstream Claude Code / codex support.
|
||||||
|
Writing to `claude.json` is best-effort scaffolding for when that lands.
|
||||||
|
- Per-bottle color affecting anything outside the dashboard agents pane (e.g.,
|
||||||
|
proposal-pane highlights, log prefixes).
|
||||||
|
- Validating or constraining label content beyond the 64-byte printable cap.
|
||||||
|
- Persisting color-pair state across dashboard restarts (color pairs are
|
||||||
|
initialized fresh each session).
|
||||||
|
- Editing the label or color of an already-running bottle.
|
||||||
|
- Exposing label/color via `./cli.py list` (out of scope for v1; trivial to
|
||||||
|
add later since the field will be in metadata).
|
||||||
|
|
||||||
|
## Design
|
||||||
|
|
||||||
|
### Data flow
|
||||||
|
|
||||||
|
```
|
||||||
|
operator input
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
BottleSpec.label, BottleSpec.color
|
||||||
|
│
|
||||||
|
├─► docker/prepare.py → BottleMetadata.label / .color → metadata.json
|
||||||
|
│
|
||||||
|
└─► agent_provider.py → claude.json {"name": label, "color": color}
|
||||||
|
(omitted when empty)
|
||||||
|
|
||||||
|
dashboard refresh
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
enumerate_active() → read_metadata(slug) → ActiveAgent.label / .color
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
_format_agent_row → label (colored) in the row string
|
||||||
|
```
|
||||||
|
|
||||||
|
### BottleSpec changes
|
||||||
|
|
||||||
|
```python
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class BottleSpec:
|
||||||
|
manifest: Manifest
|
||||||
|
agent_name: str
|
||||||
|
copy_cwd: bool
|
||||||
|
user_cwd: str
|
||||||
|
identity: str = ""
|
||||||
|
label: str = "" # operator-chosen display name; defaults to agent_name at render time
|
||||||
|
color: str = "" # one of the 16 ANSI color names, or "" for terminal default
|
||||||
|
```
|
||||||
|
|
||||||
|
`label` and `color` default to `""` so all existing callers remain valid with
|
||||||
|
no changes.
|
||||||
|
|
||||||
|
### BottleMetadata changes
|
||||||
|
|
||||||
|
Add two new fields with backward-compatible defaults:
|
||||||
|
|
||||||
|
```python
|
||||||
|
@dataclass
|
||||||
|
class BottleMetadata:
|
||||||
|
identity: str
|
||||||
|
agent_name: str
|
||||||
|
cwd: str
|
||||||
|
copy_cwd: bool
|
||||||
|
started_at: str
|
||||||
|
compose_project: str
|
||||||
|
backend: str
|
||||||
|
label: str = ""
|
||||||
|
color: str = ""
|
||||||
|
```
|
||||||
|
|
||||||
|
`metadata.json` written by older bot-bottle versions won't have these keys;
|
||||||
|
`read_metadata` already uses `dict.get` with defaults, so existing slugs load
|
||||||
|
cleanly with `label=""`, `color=""`.
|
||||||
|
|
||||||
|
### ActiveAgent changes
|
||||||
|
|
||||||
|
```python
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ActiveAgent:
|
||||||
|
backend_name: str
|
||||||
|
slug: str
|
||||||
|
agent_name: str
|
||||||
|
started_at: str
|
||||||
|
services: tuple[str, ...]
|
||||||
|
label: str = ""
|
||||||
|
color: str = ""
|
||||||
|
```
|
||||||
|
|
||||||
|
`enumerate_active()` copies `label` and `color` out of `BottleMetadata` when
|
||||||
|
constructing each `ActiveAgent`. The smolmachines backend gets the same
|
||||||
|
additions for symmetry; it reads from its own metadata path.
|
||||||
|
|
||||||
|
### Dashboard row rendering
|
||||||
|
|
||||||
|
`_format_agent_row` already falls through cleanly on missing fields. The
|
||||||
|
change is:
|
||||||
|
|
||||||
|
```python
|
||||||
|
display_name = a.label if a.label else a.agent_name
|
||||||
|
```
|
||||||
|
|
||||||
|
Color rendering uses the existing `_try_init_green()` pattern as a model.
|
||||||
|
A `_color_pair_for(color_name)` helper initialises a fresh curses color pair
|
||||||
|
for the requested named color and returns its attr (or 0 on failure). Each
|
||||||
|
unique color in the active agent list gets its own pair index. Color pairs are
|
||||||
|
allocated lazily and cached in a `dict[str, int]` that lives for the duration
|
||||||
|
of the dashboard session.
|
||||||
|
|
||||||
|
The 16 ANSI color name → curses constant mapping:
|
||||||
|
|
||||||
|
| Name | curses constant |
|
||||||
|
|------|----------------|
|
||||||
|
| `black` | `curses.COLOR_BLACK` |
|
||||||
|
| `red` | `curses.COLOR_RED` |
|
||||||
|
| `green` | `curses.COLOR_GREEN` |
|
||||||
|
| `yellow` | `curses.COLOR_YELLOW` |
|
||||||
|
| `blue` | `curses.COLOR_BLUE` |
|
||||||
|
| `magenta` | `curses.COLOR_MAGENTA` |
|
||||||
|
| `cyan` | `curses.COLOR_CYAN` |
|
||||||
|
| `white` | `curses.COLOR_WHITE` |
|
||||||
|
| `bright-*` | same constant + `curses.A_BOLD` |
|
||||||
|
|
||||||
|
Terminals that don't support color fall back to plain text (the helper returns
|
||||||
|
0, which ORed in is a no-op — same pattern as `_try_init_green`).
|
||||||
|
|
||||||
|
### Label + color prompt — dashboard
|
||||||
|
|
||||||
|
In `_new_agent_flow`, after `_picker_modal` returns a non-None name and before
|
||||||
|
`_backend_picker_modal`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
label, color = _label_color_modal(stdscr, default_label=picked)
|
||||||
|
```
|
||||||
|
|
||||||
|
`_label_color_modal` uses `curses.endwin()` → text-mode prompts → restore
|
||||||
|
(the same drop-and-resume pattern as the existing editor flow and preflight
|
||||||
|
Y/N). Two sequential prompts:
|
||||||
|
|
||||||
|
```
|
||||||
|
bot-bottle: agent label [implementer]: <operator types>
|
||||||
|
bot-bottle: color (red/green/blue/… or Enter to skip): <operator types>
|
||||||
|
```
|
||||||
|
|
||||||
|
Invalid color names are silently ignored (treated as empty). The function
|
||||||
|
returns `(label, color)` — both strings, both possibly `""`.
|
||||||
|
|
||||||
|
### Label + color prompt — CLI
|
||||||
|
|
||||||
|
In `cmd_start`, after argument parsing and before `_launch_bottle`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
label = _text_prompt_label(args.name)
|
||||||
|
color = _text_prompt_color()
|
||||||
|
```
|
||||||
|
|
||||||
|
`_text_prompt_label(default)` writes `"bot-bottle: agent label [{default}]: "`
|
||||||
|
to stderr and returns the stripped input (or `default` if blank).
|
||||||
|
`_text_prompt_color()` writes the color prompt and returns the stripped input
|
||||||
|
(or `""` if blank or invalid).
|
||||||
|
|
||||||
|
Both use `read_tty_line()` (already in `start.py`) for the read.
|
||||||
|
|
||||||
|
### Claude Code config injection
|
||||||
|
|
||||||
|
In `agent_provider.py`, where `claude_config.write_text(...)` is called,
|
||||||
|
expand the JSON dict conditionally:
|
||||||
|
|
||||||
|
```python
|
||||||
|
payload = {
|
||||||
|
"hasCompletedOnboarding": True,
|
||||||
|
"theme": "dark",
|
||||||
|
"bypassPermissionsModeAccepted": True,
|
||||||
|
"projects": claude_projects,
|
||||||
|
}
|
||||||
|
if spec.label:
|
||||||
|
payload["name"] = spec.label
|
||||||
|
if spec.color:
|
||||||
|
payload["color"] = spec.color
|
||||||
|
claude_config.write_text(json.dumps(payload, indent=2) + "\n")
|
||||||
|
```
|
||||||
|
|
||||||
|
`spec` here is the `AgentProvisionSpec` (or equivalent) that `agent_provider`
|
||||||
|
already receives; it needs `label` and `color` threaded in from `BottleSpec`
|
||||||
|
through whatever plan/provision object the provider operates on.
|
||||||
|
|
||||||
|
## Implementation chunks
|
||||||
|
|
||||||
|
Two PRs, each independently mergeable.
|
||||||
|
|
||||||
|
### Chunk 1 — schema + storage
|
||||||
|
|
||||||
|
- Add `label: str = ""` and `color: str = ""` to `BottleSpec`,
|
||||||
|
`BottleMetadata`, and `ActiveAgent`.
|
||||||
|
- `docker/prepare.py`: copy `spec.label` / `spec.color` into `BottleMetadata`.
|
||||||
|
- `docker/enumerate.py`: copy `metadata.label` / `metadata.color` into
|
||||||
|
`ActiveAgent`.
|
||||||
|
- `agent_provider.py` (or the plan object it reads): thread label/color through
|
||||||
|
to `claude.json` write.
|
||||||
|
- Smolmachines backend: parallel changes to metadata read/write and
|
||||||
|
`ActiveAgent` construction.
|
||||||
|
- No prompt changes; no UI changes. All existing behavior is identical.
|
||||||
|
|
||||||
|
### Chunk 2 — prompts + display
|
||||||
|
|
||||||
|
- `start.py`: add `_text_prompt_label` and `_text_prompt_color`; call them in
|
||||||
|
`cmd_start` before `_launch_bottle`; pass `label` / `color` into `BottleSpec`.
|
||||||
|
- `dashboard.py`: add `_label_color_modal` (drop-and-resume); call it in
|
||||||
|
`_new_agent_flow`; pass label/color into `BottleSpec`; add
|
||||||
|
`_color_pair_for` helper; update `_format_agent_row` to use `a.label` with
|
||||||
|
color rendering.
|
||||||
|
|
||||||
|
## Open questions
|
||||||
|
|
||||||
|
None.
|
||||||
@@ -1,157 +0,0 @@
|
|||||||
# PRD 0051: Launch selector
|
|
||||||
|
|
||||||
- **Status:** Active
|
|
||||||
- **Author:** claude
|
|
||||||
- **Created:** 2026-06-04
|
|
||||||
- **Issue:** #185
|
|
||||||
|
|
||||||
## Summary
|
|
||||||
|
|
||||||
When `./cli.py start` is run without an agent name, or without a backend
|
|
||||||
explicitly specified, the user currently gets an argparse error (missing
|
|
||||||
positional) or falls through to the `docker` default silently. This PRD
|
|
||||||
adds a terminal UI that appears in those gaps: a filter-select screen
|
|
||||||
built with `curses` that lets the operator pick the agent and/or backend
|
|
||||||
interactively rather than memorising names or consulting `./cli.py list`.
|
|
||||||
|
|
||||||
## Problem
|
|
||||||
|
|
||||||
With the dashboard removed (PRD 0049), starting an agent from memory is
|
|
||||||
the only path. The operator must know the exact agent name and type it
|
|
||||||
as a positional argument. For infrequent users or large manifests this
|
|
||||||
is friction. A picker that appears automatically when the name is absent
|
|
||||||
closes the gap with minimal ceremony.
|
|
||||||
|
|
||||||
The same logic applies to backends: the operator rarely wants to specify
|
|
||||||
`--backend` explicitly, but when they do they need to know the set of
|
|
||||||
registered names. A picker on an empty `--backend` makes the choice
|
|
||||||
visible.
|
|
||||||
|
|
||||||
## Goals / Success Criteria
|
|
||||||
|
|
||||||
1. `./cli.py start` (no arguments) shows an interactive agent selector;
|
|
||||||
the selected name is used exactly as if it had been passed on the
|
|
||||||
command line.
|
|
||||||
2. `./cli.py start <name>` (no `--backend`, no `BOT_BOTTLE_BACKEND`)
|
|
||||||
shows an interactive backend selector; the selected backend is used
|
|
||||||
exactly as if `--backend=<selected>` had been passed.
|
|
||||||
3. `./cli.py start <name> --backend=<b>` (both explicit) shows neither
|
|
||||||
screen — no behavioural change from today.
|
|
||||||
4. `./cli.py start` (no arguments, no env backend) shows the agent
|
|
||||||
selector first, then the backend selector.
|
|
||||||
5. The filter-select widget is a standalone utility
|
|
||||||
(`bot_bottle/cli/tui.py`) shared by both selectors.
|
|
||||||
6. Pressing `Ctrl-C` or `q` in either selector exits cleanly (exit 0).
|
|
||||||
7. The widget supports incremental filtering: typing narrows the list;
|
|
||||||
`Backspace` removes the last character; `↑`/`↓`/`j`/`k` move the
|
|
||||||
cursor; `Enter` confirms; `Esc`/`q` cancels.
|
|
||||||
8. Unit tests cover: filtering logic, cursor movement, confirm, cancel,
|
|
||||||
and the `cmd_start` dispatch (agent-absent, backend-absent,
|
|
||||||
both-explicit, both-absent).
|
|
||||||
|
|
||||||
## Non-goals
|
|
||||||
|
|
||||||
- The TUI is not a general-purpose picker exposed as a public API;
|
|
||||||
it is an internal CLI utility.
|
|
||||||
- No mouse support.
|
|
||||||
- No pagination beyond what fits in the terminal window (scroll via
|
|
||||||
cursor movement is sufficient for typical agent counts).
|
|
||||||
- No multi-select; exactly one item is chosen per invocation.
|
|
||||||
- No changes to `./cli.py resume`, `./cli.py list`, or any other
|
|
||||||
subcommand.
|
|
||||||
|
|
||||||
## Design
|
|
||||||
|
|
||||||
### `bot_bottle/cli/tui.py` — `filter_select`
|
|
||||||
|
|
||||||
```python
|
|
||||||
def filter_select(
|
|
||||||
items: list[str],
|
|
||||||
*,
|
|
||||||
title: str = "",
|
|
||||||
tty_path: str = "/dev/tty",
|
|
||||||
) -> str | None:
|
|
||||||
"""Render a filter-select picker over the items list.
|
|
||||||
|
|
||||||
Returns the selected item string, or None if the user cancelled
|
|
||||||
(Esc / q / Ctrl-C / Ctrl-D).
|
|
||||||
|
|
||||||
Opens /dev/tty directly so the picker works even when stdout/stdin
|
|
||||||
are redirected — same pattern as `read_tty_line`.
|
|
||||||
"""
|
|
||||||
```
|
|
||||||
|
|
||||||
The widget renders to the tty file descriptor opened via `curses.initscr`
|
|
||||||
(or `curses.newterm` on the tty fd so stdout remains clean for callers
|
|
||||||
that pipe `./cli.py`).
|
|
||||||
|
|
||||||
Layout (full-width, minimal):
|
|
||||||
|
|
||||||
```
|
|
||||||
Select agent (title, top line)
|
|
||||||
Filter: <query>_ (filter line)
|
|
||||||
─────────────────────────────
|
|
||||||
> researcher
|
|
||||||
implementer
|
|
||||||
codex-researcher
|
|
||||||
...
|
|
||||||
─────────────────────────────
|
|
||||||
[↑↓/jk] move [Enter] select [Esc/q] cancel
|
|
||||||
```
|
|
||||||
|
|
||||||
- Lines below the filter are the filtered items; the cursor (`>`) marks
|
|
||||||
the selection.
|
|
||||||
- The list re-renders on every keypress.
|
|
||||||
- Terminal resize is not handled (SIGWINCH); if the window is too small
|
|
||||||
the picker exits with None.
|
|
||||||
|
|
||||||
### Changes to `cmd_start`
|
|
||||||
|
|
||||||
`name` changes from a required positional to an optional one
|
|
||||||
(`nargs="?"`). The post-parse block checks:
|
|
||||||
|
|
||||||
```python
|
|
||||||
agent_name = args.name
|
|
||||||
if agent_name is None:
|
|
||||||
manifest = Manifest.resolve(USER_CWD)
|
|
||||||
agent_name = tui.filter_select(
|
|
||||||
sorted(manifest.agents.keys()),
|
|
||||||
title="Select agent",
|
|
||||||
)
|
|
||||||
if agent_name is None:
|
|
||||||
return 0 # user cancelled
|
|
||||||
|
|
||||||
backend_name = args.backend
|
|
||||||
if backend_name is None and "BOT_BOTTLE_BACKEND" not in os.environ:
|
|
||||||
backend_name = tui.filter_select(
|
|
||||||
list(known_backend_names()),
|
|
||||||
title="Select backend",
|
|
||||||
)
|
|
||||||
if backend_name is None:
|
|
||||||
return 0 # user cancelled
|
|
||||||
```
|
|
||||||
|
|
||||||
The `manifest` object is resolved before the backend selection so the
|
|
||||||
agent picker can populate itself from the real manifest. The same
|
|
||||||
`manifest` is passed to `BottleSpec`; it is not resolved a second time.
|
|
||||||
|
|
||||||
### `/dev/tty` isolation
|
|
||||||
|
|
||||||
`filter_select` opens `/dev/tty` and feeds it as the input file to
|
|
||||||
`curses.wrapper`-equivalent code (using `curses.newterm` to avoid
|
|
||||||
clobbering the caller's stdout/stderr). This keeps the picker
|
|
||||||
composable — callers can pipe `./cli.py` output without the curses
|
|
||||||
draw sequences contaminating the pipe.
|
|
||||||
|
|
||||||
## Implementation chunks
|
|
||||||
|
|
||||||
1. **`tui.py` + tests.** Add `bot_bottle/cli/tui.py` with
|
|
||||||
`filter_select` and unit tests in `tests/unit/test_cli_tui.py`.
|
|
||||||
2. **Wire into `cmd_start` + tests.** Make `name` optional, add the
|
|
||||||
two-gate dispatch, extend `tests/unit/test_cli_start_selector.py`.
|
|
||||||
3. **Activate PRD 0051.** Flip Status Draft → Active in the same commit
|
|
||||||
that lands the implementation.
|
|
||||||
|
|
||||||
## Open questions
|
|
||||||
|
|
||||||
None. Scope is fully determined by the issue description.
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
# Development and linting dependencies only.
|
|
||||||
# The bot-bottle project itself has no runtime dependencies.
|
|
||||||
# These tools are used for code quality checks in CI/CD.
|
|
||||||
|
|
||||||
pylint>=3.0.0
|
|
||||||
pyright>=1.1.300
|
|
||||||
@@ -24,6 +24,7 @@ this test runs in DinD too — no act_runner skip needed.
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
import tempfile
|
import tempfile
|
||||||
import time
|
import time
|
||||||
@@ -31,7 +32,7 @@ import unittest
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from bot_bottle import supervise
|
from bot_bottle import supervise
|
||||||
from bot_bottle.backend.docker import bottle_state
|
from bot_bottle.backend.docker import bottle_state, capability_apply
|
||||||
from bot_bottle.backend.docker.capability_apply import apply_capability_change
|
from bot_bottle.backend.docker.capability_apply import apply_capability_change
|
||||||
from bot_bottle.backend.docker.network import (
|
from bot_bottle.backend.docker.network import (
|
||||||
network_create_egress,
|
network_create_egress,
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ localhost-reach / egress-port-bypass probes) lives in chunk 2d."""
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
import os
|
import os
|
||||||
import subprocess
|
import subprocess
|
||||||
import time
|
import time
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ from pathlib import Path
|
|||||||
from bot_bottle.agent_provider import (
|
from bot_bottle.agent_provider import (
|
||||||
CODEX_HOST_CREDENTIAL_HOSTS,
|
CODEX_HOST_CREDENTIAL_HOSTS,
|
||||||
agent_provision_plan,
|
agent_provision_plan,
|
||||||
|
runtime_for,
|
||||||
)
|
)
|
||||||
from bot_bottle.egress import CODEX_HOST_CREDENTIAL_TOKEN_REF
|
from bot_bottle.egress import CODEX_HOST_CREDENTIAL_TOKEN_REF
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ from __future__ import annotations
|
|||||||
import subprocess
|
import subprocess
|
||||||
import unittest
|
import unittest
|
||||||
from typing import Callable
|
from typing import Callable
|
||||||
from unittest.mock import patch
|
from unittest.mock import MagicMock, call, patch
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ the operator confirms. Mocks the backends and stdin."""
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
import unittest
|
import unittest
|
||||||
from unittest.mock import patch, MagicMock
|
from unittest.mock import patch, MagicMock
|
||||||
|
|
||||||
|
|||||||
@@ -1,141 +0,0 @@
|
|||||||
"""Unit: cmd_start selector dispatch (PRD 0051).
|
|
||||||
|
|
||||||
Tests that cmd_start calls filter_select when name / backend are absent,
|
|
||||||
skips them when both are explicit, and returns 0 on cancel.
|
|
||||||
|
|
||||||
All actual launch work is stubbed so no container is created.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import os
|
|
||||||
import unittest
|
|
||||||
from unittest.mock import MagicMock, patch
|
|
||||||
|
|
||||||
import bot_bottle.cli.start as start_mod
|
|
||||||
import bot_bottle.cli.tui as tui_mod
|
|
||||||
|
|
||||||
|
|
||||||
def _make_manifest(agent_names: list[str]):
|
|
||||||
manifest = MagicMock()
|
|
||||||
manifest.agents = {name: MagicMock() for name in agent_names}
|
|
||||||
return manifest
|
|
||||||
|
|
||||||
|
|
||||||
class TestCmdStartSelector(unittest.TestCase):
|
|
||||||
"""Drive cmd_start with a minimal set of stubs."""
|
|
||||||
|
|
||||||
def setUp(self):
|
|
||||||
# Stub Manifest.resolve so no on-disk manifest is needed.
|
|
||||||
self._manifest = _make_manifest(["researcher", "implementer"])
|
|
||||||
self._resolve_patch = patch(
|
|
||||||
"bot_bottle.cli.start.Manifest.resolve",
|
|
||||||
return_value=self._manifest,
|
|
||||||
)
|
|
||||||
self._resolve_patch.start()
|
|
||||||
|
|
||||||
# Stub _launch_bottle so no real container work happens.
|
|
||||||
self._launch_patch = patch(
|
|
||||||
"bot_bottle.cli.start._launch_bottle",
|
|
||||||
return_value=0,
|
|
||||||
)
|
|
||||||
self._launch_mock = self._launch_patch.start()
|
|
||||||
|
|
||||||
# Stub filter_select to avoid opening /dev/tty.
|
|
||||||
self._tui_patch = patch.object(tui_mod, "filter_select")
|
|
||||||
self._tui_mock = self._tui_patch.start()
|
|
||||||
|
|
||||||
# Ensure BOT_BOTTLE_BACKEND is absent so the backend picker fires.
|
|
||||||
self._env_patch = patch.dict(os.environ, {}, clear=False)
|
|
||||||
self._env_patch.start()
|
|
||||||
os.environ.pop("BOT_BOTTLE_BACKEND", None)
|
|
||||||
|
|
||||||
def tearDown(self):
|
|
||||||
self._resolve_patch.stop()
|
|
||||||
self._launch_patch.stop()
|
|
||||||
self._tui_patch.stop()
|
|
||||||
self._env_patch.stop()
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# Both explicit — no picker shown
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
|
|
||||||
def test_both_explicit_skips_picker(self):
|
|
||||||
self._tui_mock.return_value = "researcher"
|
|
||||||
rc = start_mod.cmd_start(["--backend=docker", "researcher"])
|
|
||||||
self.assertEqual(0, rc)
|
|
||||||
self._tui_mock.assert_not_called()
|
|
||||||
self._launch_mock.assert_called_once()
|
|
||||||
_, kwargs = self._launch_mock.call_args
|
|
||||||
self.assertEqual("docker", kwargs["backend_name"])
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# Agent absent → agent picker fires; backend explicit
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
|
|
||||||
def test_agent_absent_shows_agent_picker(self):
|
|
||||||
self._tui_mock.return_value = "researcher"
|
|
||||||
rc = start_mod.cmd_start(["--backend=docker"])
|
|
||||||
self.assertEqual(0, rc)
|
|
||||||
self._tui_mock.assert_called_once()
|
|
||||||
call_kwargs = self._tui_mock.call_args
|
|
||||||
self.assertEqual(["implementer", "researcher"], call_kwargs[0][0])
|
|
||||||
self.assertIn("agent", call_kwargs[1]["title"].lower())
|
|
||||||
|
|
||||||
def test_agent_picker_cancel_returns_0(self):
|
|
||||||
self._tui_mock.return_value = None
|
|
||||||
rc = start_mod.cmd_start(["--backend=docker"])
|
|
||||||
self.assertEqual(0, rc)
|
|
||||||
self._launch_mock.assert_not_called()
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# Agent explicit, backend absent → backend picker fires
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
|
|
||||||
def test_backend_absent_shows_backend_picker(self):
|
|
||||||
self._tui_mock.return_value = "docker"
|
|
||||||
rc = start_mod.cmd_start(["researcher"])
|
|
||||||
self.assertEqual(0, rc)
|
|
||||||
self._tui_mock.assert_called_once()
|
|
||||||
call_kwargs = self._tui_mock.call_args
|
|
||||||
self.assertIn("backend", call_kwargs[1]["title"].lower())
|
|
||||||
|
|
||||||
def test_backend_picker_cancel_returns_0(self):
|
|
||||||
self._tui_mock.return_value = None
|
|
||||||
rc = start_mod.cmd_start(["researcher"])
|
|
||||||
self.assertEqual(0, rc)
|
|
||||||
self._launch_mock.assert_not_called()
|
|
||||||
|
|
||||||
def test_bot_bottle_backend_env_skips_backend_picker(self):
|
|
||||||
os.environ["BOT_BOTTLE_BACKEND"] = "docker"
|
|
||||||
try:
|
|
||||||
rc = start_mod.cmd_start(["researcher"])
|
|
||||||
finally:
|
|
||||||
os.environ.pop("BOT_BOTTLE_BACKEND", None)
|
|
||||||
self.assertEqual(0, rc)
|
|
||||||
self._tui_mock.assert_not_called()
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# Both absent → agent picker then backend picker
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
|
|
||||||
def test_both_absent_shows_both_pickers_in_order(self):
|
|
||||||
self._tui_mock.side_effect = ["researcher", "docker"]
|
|
||||||
rc = start_mod.cmd_start([])
|
|
||||||
self.assertEqual(0, rc)
|
|
||||||
self.assertEqual(2, self._tui_mock.call_count)
|
|
||||||
first_title = self._tui_mock.call_args_list[0][1]["title"].lower()
|
|
||||||
second_title = self._tui_mock.call_args_list[1][1]["title"].lower()
|
|
||||||
self.assertIn("agent", first_title)
|
|
||||||
self.assertIn("backend", second_title)
|
|
||||||
|
|
||||||
def test_both_absent_agent_cancel_skips_backend_picker(self):
|
|
||||||
self._tui_mock.side_effect = [None]
|
|
||||||
rc = start_mod.cmd_start([])
|
|
||||||
self.assertEqual(0, rc)
|
|
||||||
self.assertEqual(1, self._tui_mock.call_count)
|
|
||||||
self._launch_mock.assert_not_called()
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
unittest.main()
|
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
"""Unit tests for bot_bottle.cli.tui — filter_select internals.
|
|
||||||
|
|
||||||
We test the pure-Python logic (_filter_items, cursor movement, confirm,
|
|
||||||
cancel) by exercising the internal helpers directly, without spinning up
|
|
||||||
a real curses session (which requires a TTY).
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import unittest
|
|
||||||
|
|
||||||
from bot_bottle.cli.tui import _filter_items, filter_select
|
|
||||||
|
|
||||||
|
|
||||||
class TestFilterItems(unittest.TestCase):
|
|
||||||
def setUp(self):
|
|
||||||
self.items = ["researcher", "implementer", "codex-researcher", "reviewer"]
|
|
||||||
|
|
||||||
def test_empty_query_returns_all(self):
|
|
||||||
self.assertEqual(self.items, _filter_items(self.items, ""))
|
|
||||||
|
|
||||||
def test_query_filters_case_insensitively(self):
|
|
||||||
result = _filter_items(self.items, "RESEARCH")
|
|
||||||
self.assertEqual(["researcher", "codex-researcher"], result)
|
|
||||||
|
|
||||||
def test_no_match_returns_empty(self):
|
|
||||||
self.assertEqual([], _filter_items(self.items, "zzz"))
|
|
||||||
|
|
||||||
def test_partial_match(self):
|
|
||||||
result = _filter_items(self.items, "impl")
|
|
||||||
self.assertEqual(["implementer"], result)
|
|
||||||
|
|
||||||
def test_empty_items_returns_empty(self):
|
|
||||||
self.assertEqual([], _filter_items([], "foo"))
|
|
||||||
|
|
||||||
|
|
||||||
class TestFilterSelectEmptyItems(unittest.TestCase):
|
|
||||||
def test_returns_none_for_empty_list(self):
|
|
||||||
# No TTY needed — the short-circuit fires before opening tty.
|
|
||||||
result = filter_select([], title="Pick one", tty_path="/dev/null")
|
|
||||||
self.assertIsNone(result)
|
|
||||||
|
|
||||||
def test_returns_none_when_tty_unavailable(self):
|
|
||||||
# /nonexistent is guaranteed to not open.
|
|
||||||
result = filter_select(["a", "b"], tty_path="/nonexistent/tty")
|
|
||||||
self.assertIsNone(result)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
unittest.main()
|
|
||||||
@@ -14,6 +14,7 @@ from unittest.mock import MagicMock, patch
|
|||||||
|
|
||||||
from bot_bottle.agent_provider import (
|
from bot_bottle.agent_provider import (
|
||||||
AgentProvisionCommand,
|
AgentProvisionCommand,
|
||||||
|
AgentProvisionDir,
|
||||||
AgentProvisionFile,
|
AgentProvisionFile,
|
||||||
AgentProvisionPlan,
|
AgentProvisionPlan,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -6,7 +6,9 @@ import json
|
|||||||
import unittest
|
import unittest
|
||||||
import urllib.error
|
import urllib.error
|
||||||
from io import BytesIO
|
from io import BytesIO
|
||||||
from unittest.mock import MagicMock, patch
|
from pathlib import Path
|
||||||
|
from tempfile import mkdtemp
|
||||||
|
from unittest.mock import MagicMock, call, patch
|
||||||
|
|
||||||
from bot_bottle.contrib.gitea.deploy_key_provisioner import (
|
from bot_bottle.contrib.gitea.deploy_key_provisioner import (
|
||||||
GiteaDeployKeyProvisioner,
|
GiteaDeployKeyProvisioner,
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import unittest
|
import unittest
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
from bot_bottle.deploy_key_provisioner import DeployKeyProvisioner, get_provisioner
|
from bot_bottle.deploy_key_provisioner import DeployKeyProvisioner, get_provisioner
|
||||||
from bot_bottle.manifest import ManifestError
|
from bot_bottle.manifest import ManifestError
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ from __future__ import annotations
|
|||||||
import tempfile
|
import tempfile
|
||||||
import unittest
|
import unittest
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from unittest.mock import MagicMock
|
from unittest.mock import MagicMock, call
|
||||||
|
|
||||||
from bot_bottle.agent_provider import AgentProvisionPlan
|
from bot_bottle.agent_provider import AgentProvisionPlan
|
||||||
from bot_bottle.backend import Bottle, BottleSpec, ExecResult
|
from bot_bottle.backend import Bottle, BottleSpec, ExecResult
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ auth omission means unauthenticated."""
|
|||||||
|
|
||||||
import unittest
|
import unittest
|
||||||
|
|
||||||
from bot_bottle.manifest import ManifestError, Manifest
|
from bot_bottle.manifest import ManifestError, EgressRoute, Manifest
|
||||||
|
|
||||||
|
|
||||||
def _bottle(routes):
|
def _bottle(routes):
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import os
|
|||||||
import tempfile
|
import tempfile
|
||||||
import unittest
|
import unittest
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import cast
|
from typing import Any, cast
|
||||||
|
|
||||||
from bot_bottle.manifest import Manifest
|
from bot_bottle.manifest import Manifest
|
||||||
from bot_bottle.pipelock import (
|
from bot_bottle.pipelock import (
|
||||||
|
|||||||
@@ -220,11 +220,7 @@ class TestEgressPrintParity(unittest.TestCase):
|
|||||||
indent_prefix = ln[:idx]
|
indent_prefix = ln[:idx]
|
||||||
result.append(ln)
|
result.append(ln)
|
||||||
elif collecting:
|
elif collecting:
|
||||||
if (
|
if ln.startswith(indent_prefix) and "egress" not in ln and ":" not in ln.lstrip()[:20]:
|
||||||
ln.startswith(indent_prefix)
|
|
||||||
and "egress" not in ln
|
|
||||||
and ":" not in ln.lstrip()[:20]
|
|
||||||
):
|
|
||||||
result.append(ln)
|
result.append(ln)
|
||||||
else:
|
else:
|
||||||
break
|
break
|
||||||
|
|||||||
@@ -124,9 +124,7 @@ class TestCleanup(unittest.TestCase):
|
|||||||
)
|
)
|
||||||
results = iter([
|
results = iter([
|
||||||
_ok(), # stop succeeds
|
_ok(), # stop succeeds
|
||||||
subprocess.CompletedProcess(
|
subprocess.CompletedProcess(args=[], returncode=1, stdout="", stderr="boom"), # delete fails
|
||||||
args=[], returncode=1, stdout="", stderr="boom"
|
|
||||||
), # delete fails
|
|
||||||
_ok(), # bundle rm succeeds
|
_ok(), # bundle rm succeeds
|
||||||
])
|
])
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import json
|
|||||||
import sqlite3
|
import sqlite3
|
||||||
import subprocess
|
import subprocess
|
||||||
import tempfile
|
import tempfile
|
||||||
|
import threading
|
||||||
import unittest
|
import unittest
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|||||||
@@ -59,9 +59,7 @@ class TestSmolmachinesResolveEnv(unittest.TestCase):
|
|||||||
patch("bot_bottle.backend.smolmachines.prepare.PipelockProxy") as mock_pl,
|
patch("bot_bottle.backend.smolmachines.prepare.PipelockProxy") as mock_pl,
|
||||||
patch("bot_bottle.backend.smolmachines.prepare.Egress") as mock_eg,
|
patch("bot_bottle.backend.smolmachines.prepare.Egress") as mock_eg,
|
||||||
patch("bot_bottle.backend.smolmachines.prepare.Supervise"),
|
patch("bot_bottle.backend.smolmachines.prepare.Supervise"),
|
||||||
patch(
|
patch("bot_bottle.backend.smolmachines.prepare.agent_provision_plan") as mock_app,
|
||||||
"bot_bottle.backend.smolmachines.prepare.agent_provision_plan"
|
|
||||||
) as mock_app,
|
|
||||||
patch("bot_bottle.backend.smolmachines.prepare.runtime_for"),
|
patch("bot_bottle.backend.smolmachines.prepare.runtime_for"),
|
||||||
):
|
):
|
||||||
mock_gg.return_value.prepare.return_value = MagicMock()
|
mock_gg.return_value.prepare.return_value = MagicMock()
|
||||||
|
|||||||
@@ -37,11 +37,7 @@ from bot_bottle.supervise import (
|
|||||||
FIXED_TS = datetime(2026, 5, 25, 12, 0, 0, tzinfo=timezone.utc)
|
FIXED_TS = datetime(2026, 5, 25, 12, 0, 0, tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
def _proposal(
|
def _proposal(tool: str = TOOL_EGRESS_BLOCK, proposed: str = "{}", justification: str = "need a route") -> Proposal:
|
||||||
tool: str = TOOL_EGRESS_BLOCK,
|
|
||||||
proposed: str = "{}",
|
|
||||||
justification: str = "need a route",
|
|
||||||
) -> Proposal:
|
|
||||||
return Proposal.new(
|
return Proposal.new(
|
||||||
bottle_slug="dev",
|
bottle_slug="dev",
|
||||||
tool=tool,
|
tool=tool,
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ which hostname will land in pipelock's allowlist on approval."""
|
|||||||
|
|
||||||
import unittest
|
import unittest
|
||||||
|
|
||||||
|
from bot_bottle import supervise
|
||||||
from bot_bottle.cli import supervise as supervise_cli
|
from bot_bottle.cli import supervise as supervise_cli
|
||||||
from bot_bottle.supervise import (
|
from bot_bottle.supervise import (
|
||||||
Proposal,
|
Proposal,
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ from bot_bottle.supervise_server import (
|
|||||||
jsonrpc_error,
|
jsonrpc_error,
|
||||||
jsonrpc_result,
|
jsonrpc_result,
|
||||||
parse_jsonrpc,
|
parse_jsonrpc,
|
||||||
|
serve,
|
||||||
validate_proposed_file,
|
validate_proposed_file,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user