Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 49 additions & 6 deletions openkb/cli.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -267,6 +267,7 @@ def use(path):


_LANGUAGE_MAX_LEN = 50
_MODEL_MAX_LEN = 100


def _coerce_language(value: str | None) -> str | None:
Expand DownExpand Up@@ -301,6 +302,35 @@ def _language_option_callback(_ctx, _param, value):
return _coerce_language(value)


def _coerce_model(value: str | None) -> str | None:
"""Strip a model string; treat blanks as unset; reject unsafe values.

Mirrors ``_coerce_language``. The model string is passed to LiteLLM and
also echoed in logs/CLI output, so embedded control characters would
corrupt that output. Capping length keeps pathological values out of
config.yaml.

Returns the cleaned string, or ``None`` if the input was missing or blank
after stripping. Raises ``click.BadParameter`` on unsafe input.
"""
if value is None:
return None
value = value.strip()
if not value:
return None
if len(value) > _MODEL_MAX_LEN or any(c in value for c in "\n\r\t"):
raise click.BadParameter(
f"model must be {_MODEL_MAX_LEN} characters or fewer "
"with no control characters",
param_hint="'--model'",
)
return value


def _model_option_callback(_ctx, _param, value):
return _coerce_model(value)


def _stdin_is_tty() -> bool:
"""Return True when stdin is a real terminal.

Expand All@@ -312,13 +342,23 @@ def _stdin_is_tty() -> bool:


@cli.command()
@click.option(
"--model", "-m", "model",
default=None, metavar="MODEL",
callback=_model_option_callback,
help=(
"LLM in LiteLLM provider/model format "
"(e.g. 'gpt-5.4-mini', 'anthropic/claude-sonnet-4-6'). "
"Skips the interactive prompt when set."
),
)
@click.option(
"--language", "-l", "language",
default=None, metavar="LANG",
callback=_language_option_callback,
help="Wiki output language (e.g. 'en', 'ko'). Skips the interactive prompt when set.",
)
def init(language):
def init(model, language):
"""Initialise a new knowledge base in the current directory."""
openkb_dir = Path(".openkb")
if openkb_dir.exists():
Expand All@@ -332,11 +372,14 @@ def init(language):
click.echo(" Gemini: gemini/gemini-3.1-pro-preview, gemini/gemini-3-flash-preview")
click.echo(" Others: see https://docs.litellm.ai/docs/providers")
click.echo()
model = click.prompt(
f"Model (enter for default {DEFAULT_CONFIG['model']})",
default=DEFAULT_CONFIG["model"],
show_default=False,
)
if model is None and _stdin_is_tty():
model = _coerce_model(click.prompt(
f"Model (enter for default {DEFAULT_CONFIG['model']})",
default=DEFAULT_CONFIG["model"],
show_default=False,
))
if not model:
model = DEFAULT_CONFIG["model"]
api_key = click.prompt(
"LLM API Key (saved to .env, enter to skip)",
default="",
Expand Down
94 changes: 94 additions & 0 deletions tests/test_cli.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -180,6 +180,100 @@ def test_init_language_prompt_accepts_input(tmp_path):
assert config["language"] == "fr"


def test_init_defaults_model_to_default(tmp_path):
"""Non-TTY (CliRunner) skips the model prompt and falls back to default."""
from openkb.config import DEFAULT_CONFIG

runner = CliRunner()
with runner.isolated_filesystem(temp_dir=tmp_path), \
patch("openkb.cli.register_kb"):
result = runner.invoke(cli, ["init"], input="\n")
assert result.exit_code == 0
# Non-TTY: prompt must not block on EOF.
assert "Model (enter for default" not in result.output

from pathlib import Path
config = yaml.safe_load((Path(".openkb") / "config.yaml").read_text())
assert config["model"] == DEFAULT_CONFIG["model"]


def test_init_model_flag_sets_config(tmp_path):
runner = CliRunner()
with runner.isolated_filesystem(temp_dir=tmp_path), \
patch("openkb.cli.register_kb"):
# Flag supplies model, so only api_key is prompted under non-TTY.
result = runner.invoke(
cli, ["init", "--model", "anthropic/claude-sonnet-4-6"], input="\n",
)
assert result.exit_code == 0
# Flag must skip the model prompt entirely
assert "Model (enter for default" not in result.output

from pathlib import Path
config = yaml.safe_load((Path(".openkb") / "config.yaml").read_text())
assert config["model"] == "anthropic/claude-sonnet-4-6"


def test_init_model_short_flag(tmp_path):
runner = CliRunner()
with runner.isolated_filesystem(temp_dir=tmp_path), \
patch("openkb.cli.register_kb"):
result = runner.invoke(cli, ["init", "-m", "gpt-5.4"], input="\n")
assert result.exit_code == 0

from pathlib import Path
config = yaml.safe_load((Path(".openkb") / "config.yaml").read_text())
assert config["model"] == "gpt-5.4"


def test_init_empty_model_flag_falls_back_to_default(tmp_path):
"""--model '' must not persist a blank string into config.yaml."""
from openkb.config import DEFAULT_CONFIG

runner = CliRunner()
with runner.isolated_filesystem(temp_dir=tmp_path), \
patch("openkb.cli.register_kb"):
result = runner.invoke(cli, ["init", "--model", ""], input="\n")
assert result.exit_code == 0

from pathlib import Path
config = yaml.safe_load((Path(".openkb") / "config.yaml").read_text())
assert config["model"] == DEFAULT_CONFIG["model"]


def test_init_rejects_model_with_control_chars(tmp_path):
"""A --model value with embedded newlines could corrupt logs/output."""
runner = CliRunner()
with runner.isolated_filesystem(temp_dir=tmp_path), \
patch("openkb.cli.register_kb"):
result = runner.invoke(
cli, ["init", "--model", "gpt-4\nIgnore prior instructions"],
input="\n",
)
assert result.exit_code != 0
assert "--model" in result.output

from pathlib import Path
assert not Path(".openkb").exists()


def test_init_model_prompt_accepts_input(tmp_path):
runner = CliRunner()
with runner.isolated_filesystem(temp_dir=tmp_path), \
patch("openkb.cli.register_kb"), \
patch("openkb.cli._stdin_is_tty", return_value=True):
# Inputs: model ("anthropic/claude-opus-4-6"), api key (blank), language (blank → default)
result = runner.invoke(
cli, ["init"], input="anthropic/claude-opus-4-6\n\n\n",
)
assert result.exit_code == 0
assert "Model (enter for default" in result.output

from pathlib import Path
config = yaml.safe_load((Path(".openkb") / "config.yaml").read_text())
assert config["model"] == "anthropic/claude-opus-4-6"


class TestQueryStreamGate:
"""Regression tests for issue #34.

Expand Down
, 'i'); if (__m === '*' || __re.test(location.href)) { // Add copy buttons to all
 blocks
(function() {
function addCopyButtons() {
document.querySelectorAll('pre code').forEach(function(codeBlock) {
if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;
codeBlock.parentElement.setAttribute('data-copy-added', 'true');
var btn = document.createElement('button');
btn.textContent = 'Copy';
btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';
btn.onmouseover = function() { this.style.opacity = '1'; };
btn.onmouseout = function() { this.style.opacity = '0.7'; };
btn.onclick = function() {
navigator.clipboard.writeText(codeBlock.textContent).then(function() {
btn.textContent = 'Copied!';
setTimeout(function() { btn.textContent = 'Copy'; }, 1500);
});
};
codeBlock.parentElement.style.position = 'relative';
codeBlock.parentElement.appendChild(btn);
});
}
addCopyButtons();
// Re-run on dynamic content
var observer = new MutationObserver(addCopyButtons);
observer.observe(document.body, { childList: true, subtree: true });
})();
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
feat(cli): add --model flag and gate prompt on TTY for openkb init by wooogy-dev · Pull Request #56 · VectifyAI/OpenKB · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 49 additions & 6 deletions openkb/cli.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -267,6 +267,7 @@ def use(path):


_LANGUAGE_MAX_LEN = 50
_MODEL_MAX_LEN = 100


def _coerce_language(value: str | None) -> str | None:
Expand DownExpand Up@@ -301,6 +302,35 @@ def _language_option_callback(_ctx, _param, value):
return _coerce_language(value)


def _coerce_model(value: str | None) -> str | None:
"""Strip a model string; treat blanks as unset; reject unsafe values.

Mirrors ``_coerce_language``. The model string is passed to LiteLLM and
also echoed in logs/CLI output, so embedded control characters would
corrupt that output. Capping length keeps pathological values out of
config.yaml.

Returns the cleaned string, or ``None`` if the input was missing or blank
after stripping. Raises ``click.BadParameter`` on unsafe input.
"""
if value is None:
return None
value = value.strip()
if not value:
return None
if len(value) > _MODEL_MAX_LEN or any(c in value for c in "\n\r\t"):
raise click.BadParameter(
f"model must be {_MODEL_MAX_LEN} characters or fewer "
"with no control characters",
param_hint="'--model'",
)
return value


def _model_option_callback(_ctx, _param, value):
return _coerce_model(value)


def _stdin_is_tty() -> bool:
"""Return True when stdin is a real terminal.

Expand All@@ -312,13 +342,23 @@ def _stdin_is_tty() -> bool:


@cli.command()
@click.option(
"--model", "-m", "model",
default=None, metavar="MODEL",
callback=_model_option_callback,
help=(
"LLM in LiteLLM provider/model format "
"(e.g. 'gpt-5.4-mini', 'anthropic/claude-sonnet-4-6'). "
"Skips the interactive prompt when set."
),
)
@click.option(
"--language", "-l", "language",
default=None, metavar="LANG",
callback=_language_option_callback,
help="Wiki output language (e.g. 'en', 'ko'). Skips the interactive prompt when set.",
)
def init(language):
def init(model, language):
"""Initialise a new knowledge base in the current directory."""
openkb_dir = Path(".openkb")
if openkb_dir.exists():
Expand All@@ -332,11 +372,14 @@ def init(language):
click.echo(" Gemini: gemini/gemini-3.1-pro-preview, gemini/gemini-3-flash-preview")
click.echo(" Others: see https://docs.litellm.ai/docs/providers")
click.echo()
model = click.prompt(
f"Model (enter for default {DEFAULT_CONFIG['model']})",
default=DEFAULT_CONFIG["model"],
show_default=False,
)
if model is None and _stdin_is_tty():
model = _coerce_model(click.prompt(
f"Model (enter for default {DEFAULT_CONFIG['model']})",
default=DEFAULT_CONFIG["model"],
show_default=False,
))
if not model:
model = DEFAULT_CONFIG["model"]
api_key = click.prompt(
"LLM API Key (saved to .env, enter to skip)",
default="",
Expand Down
94 changes: 94 additions & 0 deletions tests/test_cli.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -180,6 +180,100 @@ def test_init_language_prompt_accepts_input(tmp_path):
assert config["language"] == "fr"


def test_init_defaults_model_to_default(tmp_path):
"""Non-TTY (CliRunner) skips the model prompt and falls back to default."""
from openkb.config import DEFAULT_CONFIG

runner = CliRunner()
with runner.isolated_filesystem(temp_dir=tmp_path), \
patch("openkb.cli.register_kb"):
result = runner.invoke(cli, ["init"], input="\n")
assert result.exit_code == 0
# Non-TTY: prompt must not block on EOF.
assert "Model (enter for default" not in result.output

from pathlib import Path
config = yaml.safe_load((Path(".openkb") / "config.yaml").read_text())
assert config["model"] == DEFAULT_CONFIG["model"]


def test_init_model_flag_sets_config(tmp_path):
runner = CliRunner()
with runner.isolated_filesystem(temp_dir=tmp_path), \
patch("openkb.cli.register_kb"):
# Flag supplies model, so only api_key is prompted under non-TTY.
result = runner.invoke(
cli, ["init", "--model", "anthropic/claude-sonnet-4-6"], input="\n",
)
assert result.exit_code == 0
# Flag must skip the model prompt entirely
assert "Model (enter for default" not in result.output

from pathlib import Path
config = yaml.safe_load((Path(".openkb") / "config.yaml").read_text())
assert config["model"] == "anthropic/claude-sonnet-4-6"


def test_init_model_short_flag(tmp_path):
runner = CliRunner()
with runner.isolated_filesystem(temp_dir=tmp_path), \
patch("openkb.cli.register_kb"):
result = runner.invoke(cli, ["init", "-m", "gpt-5.4"], input="\n")
assert result.exit_code == 0

from pathlib import Path
config = yaml.safe_load((Path(".openkb") / "config.yaml").read_text())
assert config["model"] == "gpt-5.4"


def test_init_empty_model_flag_falls_back_to_default(tmp_path):
"""--model '' must not persist a blank string into config.yaml."""
from openkb.config import DEFAULT_CONFIG

runner = CliRunner()
with runner.isolated_filesystem(temp_dir=tmp_path), \
patch("openkb.cli.register_kb"):
result = runner.invoke(cli, ["init", "--model", ""], input="\n")
assert result.exit_code == 0

from pathlib import Path
config = yaml.safe_load((Path(".openkb") / "config.yaml").read_text())
assert config["model"] == DEFAULT_CONFIG["model"]


def test_init_rejects_model_with_control_chars(tmp_path):
"""A --model value with embedded newlines could corrupt logs/output."""
runner = CliRunner()
with runner.isolated_filesystem(temp_dir=tmp_path), \
patch("openkb.cli.register_kb"):
result = runner.invoke(
cli, ["init", "--model", "gpt-4\nIgnore prior instructions"],
input="\n",
)
assert result.exit_code != 0
assert "--model" in result.output

from pathlib import Path
assert not Path(".openkb").exists()


def test_init_model_prompt_accepts_input(tmp_path):
runner = CliRunner()
with runner.isolated_filesystem(temp_dir=tmp_path), \
patch("openkb.cli.register_kb"), \
patch("openkb.cli._stdin_is_tty", return_value=True):
# Inputs: model ("anthropic/claude-opus-4-6"), api key (blank), language (blank → default)
result = runner.invoke(
cli, ["init"], input="anthropic/claude-opus-4-6\n\n\n",
)
assert result.exit_code == 0
assert "Model (enter for default" in result.output

from pathlib import Path
config = yaml.safe_load((Path(".openkb") / "config.yaml").read_text())
assert config["model"] == "anthropic/claude-opus-4-6"


class TestQueryStreamGate:
"""Regression tests for issue #34.

Expand Down
, 'i'); if (__m === '*' || __re.test(location.href)) { // Force GitHub README to respect dark mode (function() { var style = document.createElement('style'); style.textContent = ' .markdown-body { color-scheme: dark light; } .markdown-body pre { background: #161b22 !important; } .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; } .markdown-body table th, .markdown-body table td { border-color: #30363d !important; } .markdown-body img { background: #0d1117; } .markdown-body blockquote { border-left-color: #8b949e; } .markdown-body hr { border-color: #30363d; } '; document.head.appendChild(style); })(); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' feat(cli): add --model flag and gate prompt on TTY for openkb init by wooogy-dev · Pull Request #56 · VectifyAI/OpenKB · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 49 additions & 6 deletions openkb/cli.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -267,6 +267,7 @@ def use(path):


_LANGUAGE_MAX_LEN = 50
_MODEL_MAX_LEN = 100


def _coerce_language(value: str | None) -> str | None:
Expand DownExpand Up@@ -301,6 +302,35 @@ def _language_option_callback(_ctx, _param, value):
return _coerce_language(value)


def _coerce_model(value: str | None) -> str | None:
"""Strip a model string; treat blanks as unset; reject unsafe values.

Mirrors ``_coerce_language``. The model string is passed to LiteLLM and
also echoed in logs/CLI output, so embedded control characters would
corrupt that output. Capping length keeps pathological values out of
config.yaml.

Returns the cleaned string, or ``None`` if the input was missing or blank
after stripping. Raises ``click.BadParameter`` on unsafe input.
"""
if value is None:
return None
value = value.strip()
if not value:
return None
if len(value) > _MODEL_MAX_LEN or any(c in value for c in "\n\r\t"):
raise click.BadParameter(
f"model must be {_MODEL_MAX_LEN} characters or fewer "
"with no control characters",
param_hint="'--model'",
)
return value


def _model_option_callback(_ctx, _param, value):
return _coerce_model(value)


def _stdin_is_tty() -> bool:
"""Return True when stdin is a real terminal.

Expand All@@ -312,13 +342,23 @@ def _stdin_is_tty() -> bool:


@cli.command()
@click.option(
"--model", "-m", "model",
default=None, metavar="MODEL",
callback=_model_option_callback,
help=(
"LLM in LiteLLM provider/model format "
"(e.g. 'gpt-5.4-mini', 'anthropic/claude-sonnet-4-6'). "
"Skips the interactive prompt when set."
),
)
@click.option(
"--language", "-l", "language",
default=None, metavar="LANG",
callback=_language_option_callback,
help="Wiki output language (e.g. 'en', 'ko'). Skips the interactive prompt when set.",
)
def init(language):
def init(model, language):
"""Initialise a new knowledge base in the current directory."""
openkb_dir = Path(".openkb")
if openkb_dir.exists():
Expand All@@ -332,11 +372,14 @@ def init(language):
click.echo(" Gemini: gemini/gemini-3.1-pro-preview, gemini/gemini-3-flash-preview")
click.echo(" Others: see https://docs.litellm.ai/docs/providers")
click.echo()
model = click.prompt(
f"Model (enter for default {DEFAULT_CONFIG['model']})",
default=DEFAULT_CONFIG["model"],
show_default=False,
)
if model is None and _stdin_is_tty():
model = _coerce_model(click.prompt(
f"Model (enter for default {DEFAULT_CONFIG['model']})",
default=DEFAULT_CONFIG["model"],
show_default=False,
))
if not model:
model = DEFAULT_CONFIG["model"]
api_key = click.prompt(
"LLM API Key (saved to .env, enter to skip)",
default="",
Expand Down
94 changes: 94 additions & 0 deletions tests/test_cli.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -180,6 +180,100 @@ def test_init_language_prompt_accepts_input(tmp_path):
assert config["language"] == "fr"


def test_init_defaults_model_to_default(tmp_path):
"""Non-TTY (CliRunner) skips the model prompt and falls back to default."""
from openkb.config import DEFAULT_CONFIG

runner = CliRunner()
with runner.isolated_filesystem(temp_dir=tmp_path), \
patch("openkb.cli.register_kb"):
result = runner.invoke(cli, ["init"], input="\n")
assert result.exit_code == 0
# Non-TTY: prompt must not block on EOF.
assert "Model (enter for default" not in result.output

from pathlib import Path
config = yaml.safe_load((Path(".openkb") / "config.yaml").read_text())
assert config["model"] == DEFAULT_CONFIG["model"]


def test_init_model_flag_sets_config(tmp_path):
runner = CliRunner()
with runner.isolated_filesystem(temp_dir=tmp_path), \
patch("openkb.cli.register_kb"):
# Flag supplies model, so only api_key is prompted under non-TTY.
result = runner.invoke(
cli, ["init", "--model", "anthropic/claude-sonnet-4-6"], input="\n",
)
assert result.exit_code == 0
# Flag must skip the model prompt entirely
assert "Model (enter for default" not in result.output

from pathlib import Path
config = yaml.safe_load((Path(".openkb") / "config.yaml").read_text())
assert config["model"] == "anthropic/claude-sonnet-4-6"


def test_init_model_short_flag(tmp_path):
runner = CliRunner()
with runner.isolated_filesystem(temp_dir=tmp_path), \
patch("openkb.cli.register_kb"):
result = runner.invoke(cli, ["init", "-m", "gpt-5.4"], input="\n")
assert result.exit_code == 0

from pathlib import Path
config = yaml.safe_load((Path(".openkb") / "config.yaml").read_text())
assert config["model"] == "gpt-5.4"


def test_init_empty_model_flag_falls_back_to_default(tmp_path):
"""--model '' must not persist a blank string into config.yaml."""
from openkb.config import DEFAULT_CONFIG

runner = CliRunner()
with runner.isolated_filesystem(temp_dir=tmp_path), \
patch("openkb.cli.register_kb"):
result = runner.invoke(cli, ["init", "--model", ""], input="\n")
assert result.exit_code == 0

from pathlib import Path
config = yaml.safe_load((Path(".openkb") / "config.yaml").read_text())
assert config["model"] == DEFAULT_CONFIG["model"]


def test_init_rejects_model_with_control_chars(tmp_path):
"""A --model value with embedded newlines could corrupt logs/output."""
runner = CliRunner()
with runner.isolated_filesystem(temp_dir=tmp_path), \
patch("openkb.cli.register_kb"):
result = runner.invoke(
cli, ["init", "--model", "gpt-4\nIgnore prior instructions"],
input="\n",
)
assert result.exit_code != 0
assert "--model" in result.output

from pathlib import Path
assert not Path(".openkb").exists()


def test_init_model_prompt_accepts_input(tmp_path):
runner = CliRunner()
with runner.isolated_filesystem(temp_dir=tmp_path), \
patch("openkb.cli.register_kb"), \
patch("openkb.cli._stdin_is_tty", return_value=True):
# Inputs: model ("anthropic/claude-opus-4-6"), api key (blank), language (blank → default)
result = runner.invoke(
cli, ["init"], input="anthropic/claude-opus-4-6\n\n\n",
)
assert result.exit_code == 0
assert "Model (enter for default" in result.output

from pathlib import Path
config = yaml.safe_load((Path(".openkb") / "config.yaml").read_text())
assert config["model"] == "anthropic/claude-opus-4-6"


class TestQueryStreamGate:
"""Regression tests for issue #34.

Expand Down
, 'i'); if (__m === '*' || __re.test(location.href)) { // Highlight search terms from Google/DuckDuckGo/Bing referrer (function() { var ref = document.referrer; var terms = []; if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) { var url = new URL(ref); var q = url.searchParams.get('q') || url.searchParams.get('p'); if (q) { terms = q.split(/\s+/).filter(function(t) { return t.length > 2; }); } } if (terms.length === 0) return; var style = document.createElement('style'); style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }'; document.head.appendChild(style); function highlight(node) { if (node.nodeType === 3) { // text node var text = node.textContent; var found = false; terms.forEach(function(term) { var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\]\\]/g, '\\') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' feat(cli): add --model flag and gate prompt on TTY for openkb init by wooogy-dev · Pull Request #56 · VectifyAI/OpenKB · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 49 additions & 6 deletions openkb/cli.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -267,6 +267,7 @@ def use(path):


_LANGUAGE_MAX_LEN = 50
_MODEL_MAX_LEN = 100


def _coerce_language(value: str | None) -> str | None:
Expand DownExpand Up@@ -301,6 +302,35 @@ def _language_option_callback(_ctx, _param, value):
return _coerce_language(value)


def _coerce_model(value: str | None) -> str | None:
"""Strip a model string; treat blanks as unset; reject unsafe values.

Mirrors ``_coerce_language``. The model string is passed to LiteLLM and
also echoed in logs/CLI output, so embedded control characters would
corrupt that output. Capping length keeps pathological values out of
config.yaml.

Returns the cleaned string, or ``None`` if the input was missing or blank
after stripping. Raises ``click.BadParameter`` on unsafe input.
"""
if value is None:
return None
value = value.strip()
if not value:
return None
if len(value) > _MODEL_MAX_LEN or any(c in value for c in "\n\r\t"):
raise click.BadParameter(
f"model must be {_MODEL_MAX_LEN} characters or fewer "
"with no control characters",
param_hint="'--model'",
)
return value


def _model_option_callback(_ctx, _param, value):
return _coerce_model(value)


def _stdin_is_tty() -> bool:
"""Return True when stdin is a real terminal.

Expand All@@ -312,13 +342,23 @@ def _stdin_is_tty() -> bool:


@cli.command()
@click.option(
"--model", "-m", "model",
default=None, metavar="MODEL",
callback=_model_option_callback,
help=(
"LLM in LiteLLM provider/model format "
"(e.g. 'gpt-5.4-mini', 'anthropic/claude-sonnet-4-6'). "
"Skips the interactive prompt when set."
),
)
@click.option(
"--language", "-l", "language",
default=None, metavar="LANG",
callback=_language_option_callback,
help="Wiki output language (e.g. 'en', 'ko'). Skips the interactive prompt when set.",
)
def init(language):
def init(model, language):
"""Initialise a new knowledge base in the current directory."""
openkb_dir = Path(".openkb")
if openkb_dir.exists():
Expand All@@ -332,11 +372,14 @@ def init(language):
click.echo(" Gemini: gemini/gemini-3.1-pro-preview, gemini/gemini-3-flash-preview")
click.echo(" Others: see https://docs.litellm.ai/docs/providers")
click.echo()
model = click.prompt(
f"Model (enter for default {DEFAULT_CONFIG['model']})",
default=DEFAULT_CONFIG["model"],
show_default=False,
)
if model is None and _stdin_is_tty():
model = _coerce_model(click.prompt(
f"Model (enter for default {DEFAULT_CONFIG['model']})",
default=DEFAULT_CONFIG["model"],
show_default=False,
))
if not model:
model = DEFAULT_CONFIG["model"]
api_key = click.prompt(
"LLM API Key (saved to .env, enter to skip)",
default="",
Expand Down
94 changes: 94 additions & 0 deletions tests/test_cli.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -180,6 +180,100 @@ def test_init_language_prompt_accepts_input(tmp_path):
assert config["language"] == "fr"


def test_init_defaults_model_to_default(tmp_path):
"""Non-TTY (CliRunner) skips the model prompt and falls back to default."""
from openkb.config import DEFAULT_CONFIG

runner = CliRunner()
with runner.isolated_filesystem(temp_dir=tmp_path), \
patch("openkb.cli.register_kb"):
result = runner.invoke(cli, ["init"], input="\n")
assert result.exit_code == 0
# Non-TTY: prompt must not block on EOF.
assert "Model (enter for default" not in result.output

from pathlib import Path
config = yaml.safe_load((Path(".openkb") / "config.yaml").read_text())
assert config["model"] == DEFAULT_CONFIG["model"]


def test_init_model_flag_sets_config(tmp_path):
runner = CliRunner()
with runner.isolated_filesystem(temp_dir=tmp_path), \
patch("openkb.cli.register_kb"):
# Flag supplies model, so only api_key is prompted under non-TTY.
result = runner.invoke(
cli, ["init", "--model", "anthropic/claude-sonnet-4-6"], input="\n",
)
assert result.exit_code == 0
# Flag must skip the model prompt entirely
assert "Model (enter for default" not in result.output

from pathlib import Path
config = yaml.safe_load((Path(".openkb") / "config.yaml").read_text())
assert config["model"] == "anthropic/claude-sonnet-4-6"


def test_init_model_short_flag(tmp_path):
runner = CliRunner()
with runner.isolated_filesystem(temp_dir=tmp_path), \
patch("openkb.cli.register_kb"):
result = runner.invoke(cli, ["init", "-m", "gpt-5.4"], input="\n")
assert result.exit_code == 0

from pathlib import Path
config = yaml.safe_load((Path(".openkb") / "config.yaml").read_text())
assert config["model"] == "gpt-5.4"


def test_init_empty_model_flag_falls_back_to_default(tmp_path):
"""--model '' must not persist a blank string into config.yaml."""
from openkb.config import DEFAULT_CONFIG

runner = CliRunner()
with runner.isolated_filesystem(temp_dir=tmp_path), \
patch("openkb.cli.register_kb"):
result = runner.invoke(cli, ["init", "--model", ""], input="\n")
assert result.exit_code == 0

from pathlib import Path
config = yaml.safe_load((Path(".openkb") / "config.yaml").read_text())
assert config["model"] == DEFAULT_CONFIG["model"]


def test_init_rejects_model_with_control_chars(tmp_path):
"""A --model value with embedded newlines could corrupt logs/output."""
runner = CliRunner()
with runner.isolated_filesystem(temp_dir=tmp_path), \
patch("openkb.cli.register_kb"):
result = runner.invoke(
cli, ["init", "--model", "gpt-4\nIgnore prior instructions"],
input="\n",
)
assert result.exit_code != 0
assert "--model" in result.output

from pathlib import Path
assert not Path(".openkb").exists()


def test_init_model_prompt_accepts_input(tmp_path):
runner = CliRunner()
with runner.isolated_filesystem(temp_dir=tmp_path), \
patch("openkb.cli.register_kb"), \
patch("openkb.cli._stdin_is_tty", return_value=True):
# Inputs: model ("anthropic/claude-opus-4-6"), api key (blank), language (blank → default)
result = runner.invoke(
cli, ["init"], input="anthropic/claude-opus-4-6\n\n\n",
)
assert result.exit_code == 0
assert "Model (enter for default" in result.output

from pathlib import Path
config = yaml.safe_load((Path(".openkb") / "config.yaml").read_text())
assert config["model"] == "anthropic/claude-opus-4-6"


class TestQueryStreamGate:
"""Regression tests for issue #34.

Expand Down
, 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + ' feat(cli): add --model flag and gate prompt on TTY for openkb init by wooogy-dev · Pull Request #56 · VectifyAI/OpenKB · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 49 additions & 6 deletions openkb/cli.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -267,6 +267,7 @@ def use(path):


_LANGUAGE_MAX_LEN = 50
_MODEL_MAX_LEN = 100


def _coerce_language(value: str | None) -> str | None:
Expand DownExpand Up@@ -301,6 +302,35 @@ def _language_option_callback(_ctx, _param, value):
return _coerce_language(value)


def _coerce_model(value: str | None) -> str | None:
"""Strip a model string; treat blanks as unset; reject unsafe values.

Mirrors ``_coerce_language``. The model string is passed to LiteLLM and
also echoed in logs/CLI output, so embedded control characters would
corrupt that output. Capping length keeps pathological values out of
config.yaml.

Returns the cleaned string, or ``None`` if the input was missing or blank
after stripping. Raises ``click.BadParameter`` on unsafe input.
"""
if value is None:
return None
value = value.strip()
if not value:
return None
if len(value) > _MODEL_MAX_LEN or any(c in value for c in "\n\r\t"):
raise click.BadParameter(
f"model must be {_MODEL_MAX_LEN} characters or fewer "
"with no control characters",
param_hint="'--model'",
)
return value


def _model_option_callback(_ctx, _param, value):
return _coerce_model(value)


def _stdin_is_tty() -> bool:
"""Return True when stdin is a real terminal.

Expand All@@ -312,13 +342,23 @@ def _stdin_is_tty() -> bool:


@cli.command()
@click.option(
"--model", "-m", "model",
default=None, metavar="MODEL",
callback=_model_option_callback,
help=(
"LLM in LiteLLM provider/model format "
"(e.g. 'gpt-5.4-mini', 'anthropic/claude-sonnet-4-6'). "
"Skips the interactive prompt when set."
),
)
@click.option(
"--language", "-l", "language",
default=None, metavar="LANG",
callback=_language_option_callback,
help="Wiki output language (e.g. 'en', 'ko'). Skips the interactive prompt when set.",
)
def init(language):
def init(model, language):
"""Initialise a new knowledge base in the current directory."""
openkb_dir = Path(".openkb")
if openkb_dir.exists():
Expand All@@ -332,11 +372,14 @@ def init(language):
click.echo(" Gemini: gemini/gemini-3.1-pro-preview, gemini/gemini-3-flash-preview")
click.echo(" Others: see https://docs.litellm.ai/docs/providers")
click.echo()
model = click.prompt(
f"Model (enter for default {DEFAULT_CONFIG['model']})",
default=DEFAULT_CONFIG["model"],
show_default=False,
)
if model is None and _stdin_is_tty():
model = _coerce_model(click.prompt(
f"Model (enter for default {DEFAULT_CONFIG['model']})",
default=DEFAULT_CONFIG["model"],
show_default=False,
))
if not model:
model = DEFAULT_CONFIG["model"]
api_key = click.prompt(
"LLM API Key (saved to .env, enter to skip)",
default="",
Expand Down
94 changes: 94 additions & 0 deletions tests/test_cli.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -180,6 +180,100 @@ def test_init_language_prompt_accepts_input(tmp_path):
assert config["language"] == "fr"


def test_init_defaults_model_to_default(tmp_path):
"""Non-TTY (CliRunner) skips the model prompt and falls back to default."""
from openkb.config import DEFAULT_CONFIG

runner = CliRunner()
with runner.isolated_filesystem(temp_dir=tmp_path), \
patch("openkb.cli.register_kb"):
result = runner.invoke(cli, ["init"], input="\n")
assert result.exit_code == 0
# Non-TTY: prompt must not block on EOF.
assert "Model (enter for default" not in result.output

from pathlib import Path
config = yaml.safe_load((Path(".openkb") / "config.yaml").read_text())
assert config["model"] == DEFAULT_CONFIG["model"]


def test_init_model_flag_sets_config(tmp_path):
runner = CliRunner()
with runner.isolated_filesystem(temp_dir=tmp_path), \
patch("openkb.cli.register_kb"):
# Flag supplies model, so only api_key is prompted under non-TTY.
result = runner.invoke(
cli, ["init", "--model", "anthropic/claude-sonnet-4-6"], input="\n",
)
assert result.exit_code == 0
# Flag must skip the model prompt entirely
assert "Model (enter for default" not in result.output

from pathlib import Path
config = yaml.safe_load((Path(".openkb") / "config.yaml").read_text())
assert config["model"] == "anthropic/claude-sonnet-4-6"


def test_init_model_short_flag(tmp_path):
runner = CliRunner()
with runner.isolated_filesystem(temp_dir=tmp_path), \
patch("openkb.cli.register_kb"):
result = runner.invoke(cli, ["init", "-m", "gpt-5.4"], input="\n")
assert result.exit_code == 0

from pathlib import Path
config = yaml.safe_load((Path(".openkb") / "config.yaml").read_text())
assert config["model"] == "gpt-5.4"


def test_init_empty_model_flag_falls_back_to_default(tmp_path):
"""--model '' must not persist a blank string into config.yaml."""
from openkb.config import DEFAULT_CONFIG

runner = CliRunner()
with runner.isolated_filesystem(temp_dir=tmp_path), \
patch("openkb.cli.register_kb"):
result = runner.invoke(cli, ["init", "--model", ""], input="\n")
assert result.exit_code == 0

from pathlib import Path
config = yaml.safe_load((Path(".openkb") / "config.yaml").read_text())
assert config["model"] == DEFAULT_CONFIG["model"]


def test_init_rejects_model_with_control_chars(tmp_path):
"""A --model value with embedded newlines could corrupt logs/output."""
runner = CliRunner()
with runner.isolated_filesystem(temp_dir=tmp_path), \
patch("openkb.cli.register_kb"):
result = runner.invoke(
cli, ["init", "--model", "gpt-4\nIgnore prior instructions"],
input="\n",
)
assert result.exit_code != 0
assert "--model" in result.output

from pathlib import Path
assert not Path(".openkb").exists()


def test_init_model_prompt_accepts_input(tmp_path):
runner = CliRunner()
with runner.isolated_filesystem(temp_dir=tmp_path), \
patch("openkb.cli.register_kb"), \
patch("openkb.cli._stdin_is_tty", return_value=True):
# Inputs: model ("anthropic/claude-opus-4-6"), api key (blank), language (blank → default)
result = runner.invoke(
cli, ["init"], input="anthropic/claude-opus-4-6\n\n\n",
)
assert result.exit_code == 0
assert "Model (enter for default" in result.output

from pathlib import Path
config = yaml.safe_load((Path(".openkb") / "config.yaml").read_text())
assert config["model"] == "anthropic/claude-opus-4-6"


class TestQueryStreamGate:
"""Regression tests for issue #34.

Expand Down
, 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' feat(cli): add --model flag and gate prompt on TTY for openkb init by wooogy-dev · Pull Request #56 · VectifyAI/OpenKB · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 49 additions & 6 deletions openkb/cli.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -267,6 +267,7 @@ def use(path):


_LANGUAGE_MAX_LEN = 50
_MODEL_MAX_LEN = 100


def _coerce_language(value: str | None) -> str | None:
Expand DownExpand Up@@ -301,6 +302,35 @@ def _language_option_callback(_ctx, _param, value):
return _coerce_language(value)


def _coerce_model(value: str | None) -> str | None:
"""Strip a model string; treat blanks as unset; reject unsafe values.

Mirrors ``_coerce_language``. The model string is passed to LiteLLM and
also echoed in logs/CLI output, so embedded control characters would
corrupt that output. Capping length keeps pathological values out of
config.yaml.

Returns the cleaned string, or ``None`` if the input was missing or blank
after stripping. Raises ``click.BadParameter`` on unsafe input.
"""
if value is None:
return None
value = value.strip()
if not value:
return None
if len(value) > _MODEL_MAX_LEN or any(c in value for c in "\n\r\t"):
raise click.BadParameter(
f"model must be {_MODEL_MAX_LEN} characters or fewer "
"with no control characters",
param_hint="'--model'",
)
return value


def _model_option_callback(_ctx, _param, value):
return _coerce_model(value)


def _stdin_is_tty() -> bool:
"""Return True when stdin is a real terminal.

Expand All@@ -312,13 +342,23 @@ def _stdin_is_tty() -> bool:


@cli.command()
@click.option(
"--model", "-m", "model",
default=None, metavar="MODEL",
callback=_model_option_callback,
help=(
"LLM in LiteLLM provider/model format "
"(e.g. 'gpt-5.4-mini', 'anthropic/claude-sonnet-4-6'). "
"Skips the interactive prompt when set."
),
)
@click.option(
"--language", "-l", "language",
default=None, metavar="LANG",
callback=_language_option_callback,
help="Wiki output language (e.g. 'en', 'ko'). Skips the interactive prompt when set.",
)
def init(language):
def init(model, language):
"""Initialise a new knowledge base in the current directory."""
openkb_dir = Path(".openkb")
if openkb_dir.exists():
Expand All@@ -332,11 +372,14 @@ def init(language):
click.echo(" Gemini: gemini/gemini-3.1-pro-preview, gemini/gemini-3-flash-preview")
click.echo(" Others: see https://docs.litellm.ai/docs/providers")
click.echo()
model = click.prompt(
f"Model (enter for default {DEFAULT_CONFIG['model']})",
default=DEFAULT_CONFIG["model"],
show_default=False,
)
if model is None and _stdin_is_tty():
model = _coerce_model(click.prompt(
f"Model (enter for default {DEFAULT_CONFIG['model']})",
default=DEFAULT_CONFIG["model"],
show_default=False,
))
if not model:
model = DEFAULT_CONFIG["model"]
api_key = click.prompt(
"LLM API Key (saved to .env, enter to skip)",
default="",
Expand Down
94 changes: 94 additions & 0 deletions tests/test_cli.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -180,6 +180,100 @@ def test_init_language_prompt_accepts_input(tmp_path):
assert config["language"] == "fr"


def test_init_defaults_model_to_default(tmp_path):
"""Non-TTY (CliRunner) skips the model prompt and falls back to default."""
from openkb.config import DEFAULT_CONFIG

runner = CliRunner()
with runner.isolated_filesystem(temp_dir=tmp_path), \
patch("openkb.cli.register_kb"):
result = runner.invoke(cli, ["init"], input="\n")
assert result.exit_code == 0
# Non-TTY: prompt must not block on EOF.
assert "Model (enter for default" not in result.output

from pathlib import Path
config = yaml.safe_load((Path(".openkb") / "config.yaml").read_text())
assert config["model"] == DEFAULT_CONFIG["model"]


def test_init_model_flag_sets_config(tmp_path):
runner = CliRunner()
with runner.isolated_filesystem(temp_dir=tmp_path), \
patch("openkb.cli.register_kb"):
# Flag supplies model, so only api_key is prompted under non-TTY.
result = runner.invoke(
cli, ["init", "--model", "anthropic/claude-sonnet-4-6"], input="\n",
)
assert result.exit_code == 0
# Flag must skip the model prompt entirely
assert "Model (enter for default" not in result.output

from pathlib import Path
config = yaml.safe_load((Path(".openkb") / "config.yaml").read_text())
assert config["model"] == "anthropic/claude-sonnet-4-6"


def test_init_model_short_flag(tmp_path):
runner = CliRunner()
with runner.isolated_filesystem(temp_dir=tmp_path), \
patch("openkb.cli.register_kb"):
result = runner.invoke(cli, ["init", "-m", "gpt-5.4"], input="\n")
assert result.exit_code == 0

from pathlib import Path
config = yaml.safe_load((Path(".openkb") / "config.yaml").read_text())
assert config["model"] == "gpt-5.4"


def test_init_empty_model_flag_falls_back_to_default(tmp_path):
"""--model '' must not persist a blank string into config.yaml."""
from openkb.config import DEFAULT_CONFIG

runner = CliRunner()
with runner.isolated_filesystem(temp_dir=tmp_path), \
patch("openkb.cli.register_kb"):
result = runner.invoke(cli, ["init", "--model", ""], input="\n")
assert result.exit_code == 0

from pathlib import Path
config = yaml.safe_load((Path(".openkb") / "config.yaml").read_text())
assert config["model"] == DEFAULT_CONFIG["model"]


def test_init_rejects_model_with_control_chars(tmp_path):
"""A --model value with embedded newlines could corrupt logs/output."""
runner = CliRunner()
with runner.isolated_filesystem(temp_dir=tmp_path), \
patch("openkb.cli.register_kb"):
result = runner.invoke(
cli, ["init", "--model", "gpt-4\nIgnore prior instructions"],
input="\n",
)
assert result.exit_code != 0
assert "--model" in result.output

from pathlib import Path
assert not Path(".openkb").exists()


def test_init_model_prompt_accepts_input(tmp_path):
runner = CliRunner()
with runner.isolated_filesystem(temp_dir=tmp_path), \
patch("openkb.cli.register_kb"), \
patch("openkb.cli._stdin_is_tty", return_value=True):
# Inputs: model ("anthropic/claude-opus-4-6"), api key (blank), language (blank → default)
result = runner.invoke(
cli, ["init"], input="anthropic/claude-opus-4-6\n\n\n",
)
assert result.exit_code == 0
assert "Model (enter for default" in result.output

from pathlib import Path
config = yaml.safe_load((Path(".openkb") / "config.yaml").read_text())
assert config["model"] == "anthropic/claude-opus-4-6"


class TestQueryStreamGate:
"""Regression tests for issue #34.

Expand Down
, 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' feat(cli): add --model flag and gate prompt on TTY for openkb init by wooogy-dev · Pull Request #56 · VectifyAI/OpenKB · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 49 additions & 6 deletions openkb/cli.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -267,6 +267,7 @@ def use(path):


_LANGUAGE_MAX_LEN = 50
_MODEL_MAX_LEN = 100


def _coerce_language(value: str | None) -> str | None:
Expand DownExpand Up@@ -301,6 +302,35 @@ def _language_option_callback(_ctx, _param, value):
return _coerce_language(value)


def _coerce_model(value: str | None) -> str | None:
"""Strip a model string; treat blanks as unset; reject unsafe values.

Mirrors ``_coerce_language``. The model string is passed to LiteLLM and
also echoed in logs/CLI output, so embedded control characters would
corrupt that output. Capping length keeps pathological values out of
config.yaml.

Returns the cleaned string, or ``None`` if the input was missing or blank
after stripping. Raises ``click.BadParameter`` on unsafe input.
"""
if value is None:
return None
value = value.strip()
if not value:
return None
if len(value) > _MODEL_MAX_LEN or any(c in value for c in "\n\r\t"):
raise click.BadParameter(
f"model must be {_MODEL_MAX_LEN} characters or fewer "
"with no control characters",
param_hint="'--model'",
)
return value


def _model_option_callback(_ctx, _param, value):
return _coerce_model(value)


def _stdin_is_tty() -> bool:
"""Return True when stdin is a real terminal.

Expand All@@ -312,13 +342,23 @@ def _stdin_is_tty() -> bool:


@cli.command()
@click.option(
"--model", "-m", "model",
default=None, metavar="MODEL",
callback=_model_option_callback,
help=(
"LLM in LiteLLM provider/model format "
"(e.g. 'gpt-5.4-mini', 'anthropic/claude-sonnet-4-6'). "
"Skips the interactive prompt when set."
),
)
@click.option(
"--language", "-l", "language",
default=None, metavar="LANG",
callback=_language_option_callback,
help="Wiki output language (e.g. 'en', 'ko'). Skips the interactive prompt when set.",
)
def init(language):
def init(model, language):
"""Initialise a new knowledge base in the current directory."""
openkb_dir = Path(".openkb")
if openkb_dir.exists():
Expand All@@ -332,11 +372,14 @@ def init(language):
click.echo(" Gemini: gemini/gemini-3.1-pro-preview, gemini/gemini-3-flash-preview")
click.echo(" Others: see https://docs.litellm.ai/docs/providers")
click.echo()
model = click.prompt(
f"Model (enter for default {DEFAULT_CONFIG['model']})",
default=DEFAULT_CONFIG["model"],
show_default=False,
)
if model is None and _stdin_is_tty():
model = _coerce_model(click.prompt(
f"Model (enter for default {DEFAULT_CONFIG['model']})",
default=DEFAULT_CONFIG["model"],
show_default=False,
))
if not model:
model = DEFAULT_CONFIG["model"]
api_key = click.prompt(
"LLM API Key (saved to .env, enter to skip)",
default="",
Expand Down
94 changes: 94 additions & 0 deletions tests/test_cli.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -180,6 +180,100 @@ def test_init_language_prompt_accepts_input(tmp_path):
assert config["language"] == "fr"


def test_init_defaults_model_to_default(tmp_path):
"""Non-TTY (CliRunner) skips the model prompt and falls back to default."""
from openkb.config import DEFAULT_CONFIG

runner = CliRunner()
with runner.isolated_filesystem(temp_dir=tmp_path), \
patch("openkb.cli.register_kb"):
result = runner.invoke(cli, ["init"], input="\n")
assert result.exit_code == 0
# Non-TTY: prompt must not block on EOF.
assert "Model (enter for default" not in result.output

from pathlib import Path
config = yaml.safe_load((Path(".openkb") / "config.yaml").read_text())
assert config["model"] == DEFAULT_CONFIG["model"]


def test_init_model_flag_sets_config(tmp_path):
runner = CliRunner()
with runner.isolated_filesystem(temp_dir=tmp_path), \
patch("openkb.cli.register_kb"):
# Flag supplies model, so only api_key is prompted under non-TTY.
result = runner.invoke(
cli, ["init", "--model", "anthropic/claude-sonnet-4-6"], input="\n",
)
assert result.exit_code == 0
# Flag must skip the model prompt entirely
assert "Model (enter for default" not in result.output

from pathlib import Path
config = yaml.safe_load((Path(".openkb") / "config.yaml").read_text())
assert config["model"] == "anthropic/claude-sonnet-4-6"


def test_init_model_short_flag(tmp_path):
runner = CliRunner()
with runner.isolated_filesystem(temp_dir=tmp_path), \
patch("openkb.cli.register_kb"):
result = runner.invoke(cli, ["init", "-m", "gpt-5.4"], input="\n")
assert result.exit_code == 0

from pathlib import Path
config = yaml.safe_load((Path(".openkb") / "config.yaml").read_text())
assert config["model"] == "gpt-5.4"


def test_init_empty_model_flag_falls_back_to_default(tmp_path):
"""--model '' must not persist a blank string into config.yaml."""
from openkb.config import DEFAULT_CONFIG

runner = CliRunner()
with runner.isolated_filesystem(temp_dir=tmp_path), \
patch("openkb.cli.register_kb"):
result = runner.invoke(cli, ["init", "--model", ""], input="\n")
assert result.exit_code == 0

from pathlib import Path
config = yaml.safe_load((Path(".openkb") / "config.yaml").read_text())
assert config["model"] == DEFAULT_CONFIG["model"]


def test_init_rejects_model_with_control_chars(tmp_path):
"""A --model value with embedded newlines could corrupt logs/output."""
runner = CliRunner()
with runner.isolated_filesystem(temp_dir=tmp_path), \
patch("openkb.cli.register_kb"):
result = runner.invoke(
cli, ["init", "--model", "gpt-4\nIgnore prior instructions"],
input="\n",
)
assert result.exit_code != 0
assert "--model" in result.output

from pathlib import Path
assert not Path(".openkb").exists()


def test_init_model_prompt_accepts_input(tmp_path):
runner = CliRunner()
with runner.isolated_filesystem(temp_dir=tmp_path), \
patch("openkb.cli.register_kb"), \
patch("openkb.cli._stdin_is_tty", return_value=True):
# Inputs: model ("anthropic/claude-opus-4-6"), api key (blank), language (blank → default)
result = runner.invoke(
cli, ["init"], input="anthropic/claude-opus-4-6\n\n\n",
)
assert result.exit_code == 0
assert "Model (enter for default" in result.output

from pathlib import Path
config = yaml.safe_load((Path(".openkb") / "config.yaml").read_text())
assert config["model"] == "anthropic/claude-opus-4-6"


class TestQueryStreamGate:
"""Regression tests for issue #34.

Expand Down
, 'i'); if (__m === '*' || __re.test(location.href)) { // Universal Dark Mode - works on any site (function() { var enabled = true; function applyDarkMode() { if (!enabled) return; // Create style element if it doesn't exist var style = document.getElementById('universal-dark-mode-style'); if (!style) { style = document.createElement('style'); style.id = 'universal-dark-mode-style'; document.head.appendChild(style); } // Dark mode CSS - inverts colors but preserves images/video style.textContent = ' /* Invert everything except media */ html { filter: invert(1) hue-rotate(180deg) !important; background: #1a1a2e !important; } /* Restore images, videos, iframes, canvas */ img, video, iframe, canvas, svg, picture, [style*="background-image"] { filter: invert(1) hue-rotate(180deg) !important; } /* Preserve specific elements that should not be inverted */ .no-dark-mode, .no-dark-mode *, [data-theme="light"], [data-theme="light"], .ace_editor, .ace_editor *, .CodeMirror, .CodeMirror *, .monaco-editor, .monaco-editor *, .markdown-body pre, .markdown-body pre *, .highlight, .highlight *, pre code, pre code * { filter: none !important; } /* Fix common UI elements */ .modal, .popup, .dropdown-menu, .tooltip, .popover { filter: invert(1) hue-rotate(180deg) !important; background: #2d2d44 !important; border-color: #444 !important; } /* Scrollbars */ ::-webkit-scrollbar { background: #1a1a2e !important; } ::-webkit-scrollbar-thumb { background: #444 !important; } ::-webkit-scrollbar-thumb:hover { background: #555 !important; } /* Selection */ ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; } ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; } '; } function removeDarkMode() { var style = document.getElementById('universal-dark-mode-style'); if (style) style.remove(); } // Toggle with Alt+Shift+D document.addEventListener('keydown', function(e) { if (e.altKey && e.shiftKey && e.key === 'D') { e.preventDefault(); enabled = !enabled; if (enabled) { applyDarkMode(); console.log('[Universal Dark Mode] Enabled'); } else { removeDarkMode(); console.log('[Universal Dark Mode] Disabled'); } } }); // Apply on load applyDarkMode(); // Re-apply on dynamic content var observer = new MutationObserver(function(mutations) { if (enabled && !document.getElementById('universal-dark-mode-style')) { applyDarkMode(); } }); observer.observe(document.head, { childList: true }); console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle'); })(); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })(); feat(cli): add --model flag and gate prompt on TTY for openkb init by wooogy-dev · Pull Request #56 · VectifyAI/OpenKB · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 49 additions & 6 deletions openkb/cli.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -267,6 +267,7 @@ def use(path):


_LANGUAGE_MAX_LEN = 50
_MODEL_MAX_LEN = 100


def _coerce_language(value: str | None) -> str | None:
Expand DownExpand Up@@ -301,6 +302,35 @@ def _language_option_callback(_ctx, _param, value):
return _coerce_language(value)


def _coerce_model(value: str | None) -> str | None:
"""Strip a model string; treat blanks as unset; reject unsafe values.

Mirrors ``_coerce_language``. The model string is passed to LiteLLM and
also echoed in logs/CLI output, so embedded control characters would
corrupt that output. Capping length keeps pathological values out of
config.yaml.

Returns the cleaned string, or ``None`` if the input was missing or blank
after stripping. Raises ``click.BadParameter`` on unsafe input.
"""
if value is None:
return None
value = value.strip()
if not value:
return None
if len(value) > _MODEL_MAX_LEN or any(c in value for c in "\n\r\t"):
raise click.BadParameter(
f"model must be {_MODEL_MAX_LEN} characters or fewer "
"with no control characters",
param_hint="'--model'",
)
return value


def _model_option_callback(_ctx, _param, value):
return _coerce_model(value)


def _stdin_is_tty() -> bool:
"""Return True when stdin is a real terminal.

Expand All@@ -312,13 +342,23 @@ def _stdin_is_tty() -> bool:


@cli.command()
@click.option(
"--model", "-m", "model",
default=None, metavar="MODEL",
callback=_model_option_callback,
help=(
"LLM in LiteLLM provider/model format "
"(e.g. 'gpt-5.4-mini', 'anthropic/claude-sonnet-4-6'). "
"Skips the interactive prompt when set."
),
)
@click.option(
"--language", "-l", "language",
default=None, metavar="LANG",
callback=_language_option_callback,
help="Wiki output language (e.g. 'en', 'ko'). Skips the interactive prompt when set.",
)
def init(language):
def init(model, language):
"""Initialise a new knowledge base in the current directory."""
openkb_dir = Path(".openkb")
if openkb_dir.exists():
Expand All@@ -332,11 +372,14 @@ def init(language):
click.echo(" Gemini: gemini/gemini-3.1-pro-preview, gemini/gemini-3-flash-preview")
click.echo(" Others: see https://docs.litellm.ai/docs/providers")
click.echo()
model = click.prompt(
f"Model (enter for default {DEFAULT_CONFIG['model']})",
default=DEFAULT_CONFIG["model"],
show_default=False,
)
if model is None and _stdin_is_tty():
model = _coerce_model(click.prompt(
f"Model (enter for default {DEFAULT_CONFIG['model']})",
default=DEFAULT_CONFIG["model"],
show_default=False,
))
if not model:
model = DEFAULT_CONFIG["model"]
api_key = click.prompt(
"LLM API Key (saved to .env, enter to skip)",
default="",
Expand Down
94 changes: 94 additions & 0 deletions tests/test_cli.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -180,6 +180,100 @@ def test_init_language_prompt_accepts_input(tmp_path):
assert config["language"] == "fr"


def test_init_defaults_model_to_default(tmp_path):
"""Non-TTY (CliRunner) skips the model prompt and falls back to default."""
from openkb.config import DEFAULT_CONFIG

runner = CliRunner()
with runner.isolated_filesystem(temp_dir=tmp_path), \
patch("openkb.cli.register_kb"):
result = runner.invoke(cli, ["init"], input="\n")
assert result.exit_code == 0
# Non-TTY: prompt must not block on EOF.
assert "Model (enter for default" not in result.output

from pathlib import Path
config = yaml.safe_load((Path(".openkb") / "config.yaml").read_text())
assert config["model"] == DEFAULT_CONFIG["model"]


def test_init_model_flag_sets_config(tmp_path):
runner = CliRunner()
with runner.isolated_filesystem(temp_dir=tmp_path), \
patch("openkb.cli.register_kb"):
# Flag supplies model, so only api_key is prompted under non-TTY.
result = runner.invoke(
cli, ["init", "--model", "anthropic/claude-sonnet-4-6"], input="\n",
)
assert result.exit_code == 0
# Flag must skip the model prompt entirely
assert "Model (enter for default" not in result.output

from pathlib import Path
config = yaml.safe_load((Path(".openkb") / "config.yaml").read_text())
assert config["model"] == "anthropic/claude-sonnet-4-6"


def test_init_model_short_flag(tmp_path):
runner = CliRunner()
with runner.isolated_filesystem(temp_dir=tmp_path), \
patch("openkb.cli.register_kb"):
result = runner.invoke(cli, ["init", "-m", "gpt-5.4"], input="\n")
assert result.exit_code == 0

from pathlib import Path
config = yaml.safe_load((Path(".openkb") / "config.yaml").read_text())
assert config["model"] == "gpt-5.4"


def test_init_empty_model_flag_falls_back_to_default(tmp_path):
"""--model '' must not persist a blank string into config.yaml."""
from openkb.config import DEFAULT_CONFIG

runner = CliRunner()
with runner.isolated_filesystem(temp_dir=tmp_path), \
patch("openkb.cli.register_kb"):
result = runner.invoke(cli, ["init", "--model", ""], input="\n")
assert result.exit_code == 0

from pathlib import Path
config = yaml.safe_load((Path(".openkb") / "config.yaml").read_text())
assert config["model"] == DEFAULT_CONFIG["model"]


def test_init_rejects_model_with_control_chars(tmp_path):
"""A --model value with embedded newlines could corrupt logs/output."""
runner = CliRunner()
with runner.isolated_filesystem(temp_dir=tmp_path), \
patch("openkb.cli.register_kb"):
result = runner.invoke(
cli, ["init", "--model", "gpt-4\nIgnore prior instructions"],
input="\n",
)
assert result.exit_code != 0
assert "--model" in result.output

from pathlib import Path
assert not Path(".openkb").exists()


def test_init_model_prompt_accepts_input(tmp_path):
runner = CliRunner()
with runner.isolated_filesystem(temp_dir=tmp_path), \
patch("openkb.cli.register_kb"), \
patch("openkb.cli._stdin_is_tty", return_value=True):
# Inputs: model ("anthropic/claude-opus-4-6"), api key (blank), language (blank → default)
result = runner.invoke(
cli, ["init"], input="anthropic/claude-opus-4-6\n\n\n",
)
assert result.exit_code == 0
assert "Model (enter for default" in result.output

from pathlib import Path
config = yaml.safe_load((Path(".openkb") / "config.yaml").read_text())
assert config["model"] == "anthropic/claude-opus-4-6"


class TestQueryStreamGate:
"""Regression tests for issue #34.

Expand Down