|
12 | 12 | from claude_agent_sdk.types import ClaudeAgentOptions |
13 | 13 |
|
14 | 14 | DEFAULT_CLI_PATH = "/usr/bin/claude" |
| 15 | +_ABSENT = object() # sentinel for "field not sent on the wire" |
15 | 16 |
|
16 | 17 |
|
17 | 18 | def make_options(**kwargs: object) -> ClaudeAgentOptions: |
@@ -454,6 +455,179 @@ def test_build_command_setting_sources_included_when_provided(self): |
454 | 455 | cmd = transport._build_command() |
455 | 456 | assert "--setting-sources=user,project" in cmd |
456 | 457 |
|
| 458 | + def test_build_command_skills_none_leaves_options_untouched(self): |
| 459 | + """When skills is None (default), neither allowed_tools nor setting_sources change.""" |
| 460 | + transport = SubprocessCLITransport( |
| 461 | + prompt="test", |
| 462 | + options=make_options(), |
| 463 | + ) |
| 464 | + cmd = transport._build_command() |
| 465 | + assert "--allowedTools" not in cmd |
| 466 | + assert not any(a.startswith("--setting-sources") for a in cmd) |
| 467 | + |
| 468 | + def test_build_command_skills_all_enables_skill_tool(self): |
| 469 | + """skills='all' enables the bare Skill tool and defaults setting_sources.""" |
| 470 | + transport = SubprocessCLITransport( |
| 471 | + prompt="test", |
| 472 | + options=make_options(skills="all"), |
| 473 | + ) |
| 474 | + cmd = transport._build_command() |
| 475 | + assert "--allowedTools" in cmd |
| 476 | + assert cmd[cmd.index("--allowedTools") + 1] == "Skill" |
| 477 | + assert "--setting-sources=user,project" in cmd |
| 478 | + |
| 479 | + def test_build_command_skills_empty_list_adds_no_skill_entries(self): |
| 480 | + """skills=[] is a degenerate subset: setting_sources defaults, no Skill entries.""" |
| 481 | + transport = SubprocessCLITransport( |
| 482 | + prompt="test", |
| 483 | + options=make_options(skills=[]), |
| 484 | + ) |
| 485 | + cmd = transport._build_command() |
| 486 | + assert "--allowedTools" not in cmd |
| 487 | + assert "--setting-sources=user,project" in cmd |
| 488 | + |
| 489 | + def test_build_command_skills_named_list_uses_skill_patterns(self): |
| 490 | + """Non-empty skills list adds Skill(name) entries and defaults setting_sources.""" |
| 491 | + transport = SubprocessCLITransport( |
| 492 | + prompt="test", |
| 493 | + options=make_options(skills=["pdf", "docx"]), |
| 494 | + ) |
| 495 | + cmd = transport._build_command() |
| 496 | + assert "--allowedTools" in cmd |
| 497 | + assert cmd[cmd.index("--allowedTools") + 1] == "Skill(pdf),Skill(docx)" |
| 498 | + assert "--setting-sources=user,project" in cmd |
| 499 | + |
| 500 | + def test_build_command_skills_merges_with_existing_allowed_tools(self): |
| 501 | + """skills augment (not replace) an existing allowed_tools list.""" |
| 502 | + transport = SubprocessCLITransport( |
| 503 | + prompt="test", |
| 504 | + options=make_options( |
| 505 | + allowed_tools=["Read", "Write"], |
| 506 | + skills=["pdf"], |
| 507 | + ), |
| 508 | + ) |
| 509 | + cmd = transport._build_command() |
| 510 | + assert cmd[cmd.index("--allowedTools") + 1] == "Read,Write,Skill(pdf)" |
| 511 | + |
| 512 | + def test_build_command_skills_preserves_user_setting_sources(self): |
| 513 | + """When setting_sources is explicitly provided, skills should not override it.""" |
| 514 | + transport = SubprocessCLITransport( |
| 515 | + prompt="test", |
| 516 | + options=make_options( |
| 517 | + skills="all", |
| 518 | + setting_sources=["local"], |
| 519 | + ), |
| 520 | + ) |
| 521 | + cmd = transport._build_command() |
| 522 | + assert "--setting-sources=local" in cmd |
| 523 | + |
| 524 | + def test_build_command_skills_does_not_mutate_options(self): |
| 525 | + """Applying skills defaults must not mutate the caller's options object.""" |
| 526 | + options = make_options(allowed_tools=["Read"], skills=["pdf"]) |
| 527 | + transport = SubprocessCLITransport(prompt="test", options=options) |
| 528 | + transport._build_command() |
| 529 | + assert options.allowed_tools == ["Read"] |
| 530 | + assert options.setting_sources is None |
| 531 | + |
| 532 | + def test_build_command_skills_does_not_duplicate_entries(self): |
| 533 | + """Injecting Skill entries is idempotent when caller already listed them.""" |
| 534 | + transport = SubprocessCLITransport( |
| 535 | + prompt="test", |
| 536 | + options=make_options( |
| 537 | + allowed_tools=["Skill(pdf)"], |
| 538 | + skills=["pdf"], |
| 539 | + ), |
| 540 | + ) |
| 541 | + cmd = transport._build_command() |
| 542 | + assert cmd[cmd.index("--allowedTools") + 1] == "Skill(pdf)" |
| 543 | + |
| 544 | + @pytest.mark.parametrize( |
| 545 | + ("skills", "extra", "want_tools", "want_sources", "want_init_skills"), |
| 546 | + [ |
| 547 | + # (1) default: no auto-config |
| 548 | + (None, {}, None, None, _ABSENT), |
| 549 | + # (2) old manual way still works (skills=None, user wires it) |
| 550 | + ( |
| 551 | + None, |
| 552 | + { |
| 553 | + "allowed_tools": ["Skill", "Read"], |
| 554 | + "setting_sources": ["user", "project"], |
| 555 | + }, |
| 556 | + "Skill,Read", |
| 557 | + "user,project", |
| 558 | + _ABSENT, |
| 559 | + ), |
| 560 | + # (3) "all": bare Skill, default sources, no wire filter |
| 561 | + ("all", {}, "Skill", "user,project", _ABSENT), |
| 562 | + # (4) named subset |
| 563 | + ( |
| 564 | + ["pdf", "docx"], |
| 565 | + {}, |
| 566 | + "Skill(pdf),Skill(docx)", |
| 567 | + "user,project", |
| 568 | + ["pdf", "docx"], |
| 569 | + ), |
| 570 | + # (5) subset + explicit setting_sources (user wins) |
| 571 | + ( |
| 572 | + ["pdf"], |
| 573 | + {"setting_sources": ["project"]}, |
| 574 | + "Skill(pdf)", |
| 575 | + "project", |
| 576 | + ["pdf"], |
| 577 | + ), |
| 578 | + # (6) subset merges into existing allowed_tools |
| 579 | + ( |
| 580 | + ["pdf"], |
| 581 | + {"allowed_tools": ["Read", "Bash"]}, |
| 582 | + "Read,Bash,Skill(pdf)", |
| 583 | + "user,project", |
| 584 | + ["pdf"], |
| 585 | + ), |
| 586 | + # (7) empty list = degenerate subset (not "all") |
| 587 | + ([], {}, None, "user,project", []), |
| 588 | + ], |
| 589 | + ids=[ |
| 590 | + "default-none", |
| 591 | + "old-manual", |
| 592 | + "all", |
| 593 | + "subset", |
| 594 | + "subset+explicit-sources", |
| 595 | + "subset+merge-tools", |
| 596 | + "empty-list", |
| 597 | + ], |
| 598 | + ) |
| 599 | + def test_skills_option_matrix( |
| 600 | + self, skills, extra, want_tools, want_sources, want_init_skills |
| 601 | + ): |
| 602 | + """Documented behavior table for ClaudeAgentOptions.skills. |
| 603 | +
|
| 604 | + Asserts the full (input) -> (allowedTools, setting_sources, |
| 605 | + initialize.skills) mapping in one place. See also |
| 606 | + test_query.py::test_initialize_* for the wire-level half. |
| 607 | + """ |
| 608 | + transport = SubprocessCLITransport( |
| 609 | + prompt="test", |
| 610 | + options=make_options(skills=skills, **extra), |
| 611 | + ) |
| 612 | + cmd = transport._build_command() |
| 613 | + |
| 614 | + if want_tools is None: |
| 615 | + assert "--allowedTools" not in cmd |
| 616 | + else: |
| 617 | + assert cmd[cmd.index("--allowedTools") + 1] == want_tools |
| 618 | + |
| 619 | + if want_sources is None: |
| 620 | + assert not any(a.startswith("--setting-sources") for a in cmd) |
| 621 | + else: |
| 622 | + assert f"--setting-sources={want_sources}" in cmd |
| 623 | + |
| 624 | + # Wire-level: what the Query layer would send on initialize. |
| 625 | + # 'all' and None both omit the field; only an explicit list is sent. |
| 626 | + if want_init_skills is _ABSENT: |
| 627 | + assert not isinstance(skills, list) |
| 628 | + else: |
| 629 | + assert skills == want_init_skills |
| 630 | + |
457 | 631 | def test_build_command_with_extra_args(self): |
458 | 632 | """Test building CLI command with extra_args for future flags.""" |
459 | 633 | transport = SubprocessCLITransport( |
|
0 commit comments