> ## Documentation Index
> Fetch the complete documentation index at: https://felimet-hub.jmcores.com/llms.txt
> Use this file to discover all available pages before exploring further.

# 04-10 CLI First: How CLI and MCP Differ, and Why CLI Wins by Default

> For anything a CLI can do, use the CLI first. MCP is the fallback, not the default. This unit covers the fundamental differences between CLI and MCP: token cost, composability, auditability, authentication, and state management, with a quantified worked example of both paths on the same workflow.

export const ToolCompare = ({lang = "zh", tools = [], dimensions = [], defaultSelected, notes = []}) => {
  const UI = lang === "en" ? {
    allTools: "All",
    recommend: "Recommended",
    noSupport: "N/A",
    minOneToolW: "Select at least one tool",
    mobileLabel: "Tool",
    dimensionLbl: "Dimension",
    notes: "Notes"
  } : {
    allTools: "全選",
    recommend: "推薦",
    noSupport: "無對應",
    minOneToolW: "至少選一個工具",
    mobileLabel: "工具",
    dimensionLbl: "維度",
    notes: "注意事項"
  };
  const ACCENT_L = "#bf7551";
  const ACCENT_D = "#cf8a68";
  const REC_BDR_L = "rgba(191,117,81,0.45)";
  const REC_BG_L = "rgba(191,117,81,0.06)";
  const REC_BDR_D = "rgba(207,138,104,0.45)";
  const REC_BG_D = "rgba(207,138,104,0.08)";
  const looksLikePath = val => typeof val === "string" && (/[/\\.*:]/).test(val);
  const safeTools = Array.isArray(tools) ? tools : [];
  const safeDimensions = Array.isArray(dimensions) ? dimensions : [];
  if (safeTools.length === 0 || safeDimensions.length === 0) return null;
  const allIds = safeTools.map(t => t.id);
  const initSelected = Array.isArray(defaultSelected) && defaultSelected.length > 0 ? defaultSelected.filter(id => allIds.includes(id)) : allIds;
  const [selectedIds, setSelectedIds] = useState(initSelected.length > 0 ? initSelected : allIds);
  const [expandedCell, setExpandedCell] = useState(null);
  const [mobileTool, setMobileTool] = useState(initSelected[0] || allIds[0]);
  const [isDark, setIsDark] = useState(false);
  useEffect(() => {
    const detect = () => setIsDark(document.documentElement.classList.contains("dark"));
    detect();
    const obs = new MutationObserver(detect);
    obs.observe(document.documentElement, {
      attributes: true,
      attributeFilter: ["class"]
    });
    return () => obs.disconnect();
  }, []);
  const toggleTool = id => {
    setSelectedIds(prev => {
      const next = prev.includes(id) ? prev.filter(x => x !== id) : [...prev, id];
      if (next.length === 0) return prev;
      if (expandedCell) {
        const [, cellToolId] = expandedCell.split(":");
        if (!next.includes(cellToolId)) setExpandedCell(null);
      }
      return next;
    });
  };
  const isAllSelected = selectedIds.length === allIds.length;
  const toggleAll = () => {
    if (isAllSelected) {
      setSelectedIds([allIds[0]]);
    } else {
      setSelectedIds(allIds);
    }
    setExpandedCell(null);
  };
  const visibleTools = safeTools.filter(t => selectedIds.includes(t.id));
  const cellKey = (dimId, toolId) => dimId + ":" + toolId;
  const toggleExpand = (dimId, toolId) => {
    const key = cellKey(dimId, toolId);
    setExpandedCell(prev => prev === key ? null : key);
  };
  const renderValue = cell => {
    if (cell.na) {
      return <span className="tc-na">{UI.noSupport}</span>;
    }
    const useMono = cell.mono === true || cell.mono !== false && looksLikePath(cell.value);
    return useMono ? <code className="tc-code">{cell.value}</code> : <span>{cell.value}</span>;
  };
  const css = `
  /* ── 根容器 ── */
  .tc-root {
    --tc-bg:           #FAF8F3;
    --tc-surface:      rgba(0,0,0,0.022);
    --tc-stripe:       rgba(0,0,0,0.028);
    --tc-border:       rgba(0,0,0,0.09);
    --tc-text:         #2b2722;
    --tc-dim:          #6f6a62;
    --tc-faint:        #8a8378;
    --tc-accent:       ${ACCENT_L};
    --tc-accent-bg:    ${REC_BG_L};
    --tc-accent-bdr:   ${REC_BDR_L};
    --tc-expand-bg:    rgba(0,0,0,0.016);
    --tc-code-bg:      rgba(0,0,0,0.055);
    --tc-na-color:     #9a9490;
    --tc-pill-on-bg:   rgba(191,117,81,0.10);
    --tc-pill-on-bdr:  rgba(191,117,81,0.35);
    --tc-pill-on-txt:  #a05c38;
    border: 1px solid var(--tc-border);
    border-radius: 14px;
    background: var(--tc-bg);
    color: var(--tc-text);
    overflow: hidden;
    font-size: 14px;
  }
  .dark .tc-root {
    --tc-bg:           #1b1a18;
    --tc-surface:      rgba(255,255,255,0.03);
    --tc-stripe:       rgba(255,255,255,0.025);
    --tc-border:       rgba(255,255,255,0.08);
    --tc-text:         #e7e3da;
    --tc-dim:          #a8a299;
    --tc-faint:        #706b64;
    --tc-accent:       ${ACCENT_D};
    --tc-accent-bg:    ${REC_BG_D};
    --tc-accent-bdr:   ${REC_BDR_D};
    --tc-expand-bg:    rgba(255,255,255,0.022);
    --tc-code-bg:      rgba(255,255,255,0.08);
    --tc-na-color:     #6e6b65;
    --tc-pill-on-bg:   rgba(207,138,104,0.12);
    --tc-pill-on-bdr:  rgba(207,138,104,0.35);
    --tc-pill-on-txt:  ${ACCENT_D};
  }

  /* ── 精簡篩選列 ── */
  .tc-filter-bar {
    display: flex;
    align-items: center;
    gap: 0;
    padding: 0 14px;
    border-bottom: 1px solid var(--tc-border);
    background: var(--tc-surface);
    overflow-x: auto;
    overflow-y: hidden;
    scrollbar-width: none;
    -webkit-overflow-scrolling: touch;
    /* 單行、不折行，高度由內容決定（約 36px） */
    flex-wrap: nowrap;
    white-space: nowrap;
    min-height: 36px;
  }
  .tc-filter-bar::-webkit-scrollbar { display: none; }

  /* 分隔竿（All 後面） */
  .tc-filter-sep {
    flex-shrink: 0;
    width: 1px;
    height: 14px;
    background: var(--tc-border);
    margin: 0 10px 0 6px;
    align-self: center;
  }

  /* pill 基底 — 極輕量文字標籤 */
  .tc-pill {
    display: inline-flex;
    align-items: center;
    gap: 4px;
    padding: 5px 9px;
    border-radius: 5px;
    border: 1px solid transparent;
    background: transparent;
    color: var(--tc-faint);
    font: inherit;
    font-size: 12px;
    font-weight: 500;
    letter-spacing: 0.01em;
    cursor: pointer;
    transition: color 0.12s, background 0.12s, border-color 0.12s;
    flex-shrink: 0;
    white-space: nowrap;
    /* 行高對齊 filter-bar */
    margin: 5px 1px;
  }
  .tc-pill:hover {
    color: var(--tc-text);
    background: rgba(0,0,0,0.04);
  }
  .dark .tc-pill:hover {
    background: rgba(255,255,255,0.05);
  }
  /* 選中態：細框 + 淡底 + 文字加深（非按鈕感，保持輕量） */
  .tc-pill-on {
    color: var(--tc-pill-on-txt);
    background: var(--tc-pill-on-bg);
    border-color: var(--tc-pill-on-bdr);
    font-weight: 600;
  }
  /* 全選 pill — 略小一點字型 */
  .tc-pill-all {
    font-size: 11px;
    font-weight: 600;
    letter-spacing: 0.03em;
    text-transform: uppercase;
    padding: 4px 8px;
    color: var(--tc-dim);
  }
  .tc-pill-all.tc-pill-on {
    color: var(--tc-pill-on-txt);
  }
  /* 選中小圓點 */
  .tc-pill-dot {
    width: 5px;
    height: 5px;
    border-radius: 50%;
    background: var(--tc-accent);
    flex-shrink: 0;
  }

  /* ── 桌面表格 ── */
  .tc-table-wrap {
    overflow: hidden;
  }
  /* Mintlify 的 MDX 渲染器會自動把 <table> 包進 data-table-wrapper：
   * 加 -mx-[var(--page-padding)] 負邊距 + w-[calc(100%+padding*2)] 全寬 + py-[1em]，
   * 讓表格往外溢出，撐破 tc-root 的圓角容器（2026-06-12 線上實證跑版）。
   * 中和它：邊距歸零、寬度收回 100%、把橫向捲動容器設在這層（sticky 左欄靠它）。 */
  .tc-root [data-table-wrapper] {
    margin: 0 !important;
    width: 100% !important;
    max-width: 100% !important;
    padding: 0 !important;
    overflow-x: auto;
    contain: none !important;
  }
  .tc-root [data-table-wrapper] > div {
    padding: 0 !important;
    margin: 0 !important;
  }
  .tc-root [data-table-wrapper]::-webkit-scrollbar { height: 4px; }
  .tc-root [data-table-wrapper]::-webkit-scrollbar-thumb { background: var(--tc-border); border-radius: 2px; }
  .tc-table {
    width: 100%;
    border-collapse: collapse;
    margin: 0 !important;
  }

  /* 表頭 */
  .tc-thead th {
    padding: 9px 15px;
    text-align: left;
    font-size: 11px;
    font-weight: 700;
    letter-spacing: 0.06em;
    text-transform: uppercase;
    color: var(--tc-faint);
    border-bottom: 2px solid var(--tc-border);
    background: var(--tc-surface);
    white-space: nowrap;
  }
  /* 維度欄（sticky 左欄） */
  .tc-thead th:first-child,
  .tc-td-dim {
    position: sticky;
    left: 0;
    z-index: 1;
  }
  .tc-thead th:first-child {
    width: 160px;
    min-width: 130px;
    background: var(--tc-surface);
    border-right: 1px solid var(--tc-border);
    z-index: 2;
  }

  /* 維度標籤欄 */
  .tc-td-dim {
    padding: 13px 15px;
    font-size: 12.5px;
    font-weight: 700;
    color: var(--tc-dim);
    vertical-align: middle;
    white-space: nowrap;
    border-bottom: 1px solid var(--tc-border);
    border-right: 1px solid var(--tc-border);
    background: var(--tc-surface);
  }

  /* 斑馬紋：奇數維度列 */
  .tc-row-even .tc-td-dim,
  .tc-row-even .tc-td {
    background-color: var(--tc-stripe);
  }
  .tc-row-even .tc-td-dim {
    background: color-mix(in srgb, var(--tc-surface) 70%, var(--tc-stripe) 30%);
  }

  /* 資料儲存格 */
  .tc-td {
    padding: 0;
    border-bottom: 1px solid var(--tc-border);
    border-left: 1px solid var(--tc-border);
    vertical-align: top;
    min-width: 150px;
  }
  .tc-td-inner {
    display: flex;
    flex-direction: column;
  }

  /* 主值行（可點擊） */
  .tc-cell-btn {
    display: flex;
    align-items: flex-start;
    gap: 6px;
    width: 100%;
    text-align: left;
    background: transparent;
    border: none;
    padding: 13px 15px;
    color: inherit;
    font: inherit;
    font-size: 13px;
    line-height: 1.5;
    cursor: pointer;
    transition: background 0.11s;
    -webkit-tap-highlight-color: transparent;
  }
  .tc-cell-btn:hover {
    background: rgba(0,0,0,0.03);
  }
  .dark .tc-cell-btn:hover {
    background: rgba(255,255,255,0.03);
  }
  .tc-cell-btn-on {
    background: var(--tc-expand-bg);
  }

  /* 推薦標記：左 3px accent 邊框 + 淡底 */
  .tc-td-recommend {
    border-left: 3px solid var(--tc-accent-bdr);
    background: var(--tc-accent-bg);
  }
  .tc-td-recommend .tc-cell-btn:hover {
    background: rgba(191,117,81,0.05);
  }

  /* recommend 角標：超小 badge */
  .tc-rec-badge {
    flex-shrink: 0;
    margin-top: 1px;
    font-size: 9px;
    font-weight: 700;
    letter-spacing: 0.05em;
    text-transform: uppercase;
    color: var(--tc-accent);
    border: 1px solid var(--tc-accent-bdr);
    border-radius: 3px;
    padding: 1px 4px;
    white-space: nowrap;
    opacity: 0.85;
  }

  /* na 樣式 */
  .tc-na {
    font-style: italic;
    color: var(--tc-na-color);
    font-size: 12.5px;
    opacity: 0.7;
  }

  /* 等寬值 */
  .tc-code {
    font-family: "JetBrains Mono", "Fira Code", "Cascadia Code", monospace;
    font-size: 12px;
    background: var(--tc-code-bg);
    border-radius: 3px;
    padding: 1px 4px;
    word-break: break-all;
  }

  /* 展開詳情列 */
  .tc-detail-row td {
    padding: 0;
    border-bottom: 1px solid var(--tc-border);
  }
  .tc-detail-cell {
    padding: 10px 15px 12px;
    font-size: 12.5px;
    line-height: 1.65;
    color: var(--tc-dim);
    background: var(--tc-expand-bg);
    border-top: 1px dashed var(--tc-border);
    border-left: 1px solid var(--tc-border);
  }
  .tc-detail-cell:first-child {
    border-left: none;
    font-weight: 700;
    color: var(--tc-faint);
    font-size: 11px;
    text-transform: uppercase;
    letter-spacing: 0.05em;
    vertical-align: top;
    padding-top: 12px;
    background: var(--tc-surface);
    white-space: nowrap;
    width: 160px;
  }
  .tc-detail-recommend {
    border-left: 3px solid var(--tc-accent-bdr) !important;
    background: var(--tc-accent-bg) !important;
  }

  /* 展開箭頭 */
  .tc-chevron {
    flex-shrink: 0;
    margin-top: 3px;
    opacity: 0.35;
    transition: transform 0.14s, opacity 0.14s;
  }
  .tc-chevron-open {
    transform: rotate(90deg);
    opacity: 0.65;
  }

  /* ── 注腳 ── */
  .tc-notes {
    border-top: 1px solid var(--tc-border);
    padding: 10px 16px 12px;
    display: flex;
    flex-direction: column;
    gap: 4px;
  }
  .tc-notes-label {
    font-size: 10.5px;
    font-weight: 700;
    letter-spacing: 0.07em;
    text-transform: uppercase;
    color: var(--tc-faint);
    margin-bottom: 3px;
  }
  .tc-note-item {
    font-size: 12.5px;
    color: var(--tc-dim);
    line-height: 1.55;
    padding-left: 14px;
    position: relative;
  }
  .tc-note-item::before {
    content: "*";
    position: absolute;
    left: 2px;
    color: var(--tc-faint);
  }

  /* ── 手機模式 ─────────────────────────────────────────── */
  .tc-mobile { display: none; }
  @media (max-width: 700px) {
    .tc-filter-bar { display: none; }
    .tc-table-wrap  { display: none; }
    .tc-mobile      { display: block; }

    .tc-mob-tabs {
      display: flex;
      overflow-x: auto;
      border-bottom: 1px solid var(--tc-border);
      -webkit-overflow-scrolling: touch;
      scrollbar-width: none;
    }
    .tc-mob-tabs::-webkit-scrollbar { display: none; }
    .tc-mob-tab {
      flex: 1 0 auto;
      padding: 10px 16px;
      background: transparent;
      border: none;
      border-bottom: 2px solid transparent;
      color: var(--tc-dim);
      font: inherit;
      font-size: 13px;
      font-weight: 500;
      cursor: pointer;
      white-space: nowrap;
      transition: color 0.12s, border-color 0.12s;
    }
    .tc-mob-tab-on {
      color: var(--tc-accent);
      border-bottom-color: var(--tc-accent);
      font-weight: 700;
    }
    .tc-mob-cards {
      padding: 12px 0 4px;
    }
    .tc-mob-card {
      border-bottom: 1px solid var(--tc-border);
      padding: 12px 16px;
    }
    .tc-mob-card:last-child { border-bottom: none; }
    .tc-mob-dim {
      font-size: 11px;
      font-weight: 700;
      letter-spacing: 0.05em;
      text-transform: uppercase;
      color: var(--tc-faint);
      margin-bottom: 6px;
    }
    .tc-mob-val {
      font-size: 13.5px;
      line-height: 1.55;
    }
    .tc-mob-rec {
      display: inline-block;
      margin-top: 6px;
      font-size: 9.5px;
      font-weight: 700;
      letter-spacing: 0.05em;
      text-transform: uppercase;
      color: var(--tc-accent);
      border: 1px solid var(--tc-accent-bdr);
      border-radius: 3px;
      padding: 1px 5px;
    }
    .tc-mob-detail {
      margin-top: 7px;
      font-size: 12.5px;
      line-height: 1.65;
      color: var(--tc-dim);
    }
    .tc-notes { padding: 11px 16px 13px; }
  }
  `;
  const renderDesktopTable = () => <div className="tc-table-wrap">
      <table className="tc-table">
        <thead className="tc-thead">
          <tr>
            <th>{UI.dimensionLbl}</th>
            {visibleTools.map(tool => <th key={tool.id}>{tool.label}</th>)}
          </tr>
        </thead>
        <tbody>
          {}
          {safeDimensions.flatMap((dim, dimIdx) => {
    const isAnyExpanded = visibleTools.some(t => expandedCell === cellKey(dim.id, t.id));
    const stripeClass = dimIdx % 2 === 1 ? " tc-row-even" : "";
    const rows = [];
    rows.push(<tr key={dim.id} className={"tc-row" + stripeClass}>
                <td className="tc-td-dim">{dim.label}</td>
                {visibleTools.map(tool => {
      const cell = (dim.cells || ({}))[tool.id] || ({});
      const key = cellKey(dim.id, tool.id);
      const isOpen = expandedCell === key;
      const hasDetail = !!cell.detail;
      const isRec = !!cell.recommend;
      return <td key={tool.id} className={"tc-td" + (isRec ? " tc-td-recommend" : "")}>
                      <div className="tc-td-inner">
                        <button type="button" className={"tc-cell-btn" + (isOpen ? " tc-cell-btn-on" : "")} onClick={hasDetail ? () => toggleExpand(dim.id, tool.id) : undefined} style={hasDetail ? {} : {
        cursor: "default"
      }} aria-expanded={hasDetail ? String(isOpen) : undefined}>
                          <span style={{
        flex: "1 1 0",
        minWidth: 0
      }}>
                            {renderValue(cell)}
                          </span>
                          {isRec && <span className="tc-rec-badge">{UI.recommend}</span>}
                          {hasDetail && <svg className={"tc-chevron" + (isOpen ? " tc-chevron-open" : "")} width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
                              <polyline points="9 18 15 12 9 6" />
                            </svg>}
                        </button>
                      </div>
                    </td>;
    })}
              </tr>);
    if (isAnyExpanded) {
      const openToolId = visibleTools.find(t => expandedCell === cellKey(dim.id, t.id))?.id;
      const openCell = openToolId ? (dim.cells || ({}))[openToolId] || ({}) : {};
      if (openCell.detail) {
        const openTool = safeTools.find(t => t.id === openToolId);
        const isRec = !!openCell.recommend;
        rows.push(<tr key={dim.id + "-detail"} className="tc-detail-row">
                    <td className="tc-detail-cell">
                      {openTool ? openTool.label : ""}
                    </td>
                    <td colSpan={visibleTools.length} className={"tc-detail-cell" + (isRec ? " tc-detail-recommend" : "")}>
                      {openCell.detail}
                    </td>
                  </tr>);
      }
    }
    return rows;
  })}
        </tbody>
      </table>
    </div>;
  const renderMobile = () => {
    const activeTool = safeTools.find(t => t.id === mobileTool) || safeTools[0];
    return <div className="tc-mobile">
        <div className="tc-mob-tabs" role="tablist">
          {safeTools.map(tool => <button key={tool.id} type="button" role="tab" aria-selected={tool.id === mobileTool ? "true" : "false"} className={"tc-mob-tab" + (tool.id === mobileTool ? " tc-mob-tab-on" : "")} onClick={() => setMobileTool(tool.id)}>
              {tool.label}
            </button>)}
        </div>

        <div className="tc-mob-cards">
          {safeDimensions.map(dim => {
      const cell = (dim.cells || ({}))[activeTool.id] || ({});
      const isRec = !!cell.recommend;
      return <div key={dim.id} className="tc-mob-card">
                <div className="tc-mob-dim">{dim.label}</div>
                <div className="tc-mob-val">{renderValue(cell)}</div>
                {isRec && <div className="tc-mob-rec">{UI.recommend}</div>}
                {cell.detail && <div className="tc-mob-detail">{cell.detail}</div>}
              </div>;
    })}
        </div>
      </div>;
  };
  return <div className="tc-root">
      <style>{css}</style>

      {}
      <div className="tc-filter-bar">
        <button type="button" className={"tc-pill tc-pill-all" + (isAllSelected ? " tc-pill-on" : "")} onClick={toggleAll} aria-pressed={String(isAllSelected)}>
          {UI.allTools}
        </button>

        {}
        <span className="tc-filter-sep" aria-hidden="true" />

        {safeTools.map(tool => {
    const isOn = selectedIds.includes(tool.id);
    return <button key={tool.id} type="button" className={"tc-pill" + (isOn ? " tc-pill-on" : "")} onClick={() => toggleTool(tool.id)} aria-pressed={String(isOn)}>
              {isOn && <span className="tc-pill-dot" aria-hidden="true" />}
              {tool.label}
            </button>;
  })}
      </div>

      {}
      {visibleTools.length > 0 && renderDesktopTable()}

      {}
      {renderMobile()}

      {}
      {notes.length > 0 && <div className="tc-notes">
          <div className="tc-notes-label">{UI.notes}</div>
          {notes.map((note, i) => <div key={i} className="tc-note-item">{note}</div>)}
        </div>}
    </div>;
};

export const LearnerPrimer = ({items = [], lang = "zh"}) => {
  const t = lang === "en" ? {
    title: "Before this unit, be honest with yourself",
    sub: "If you can't answer these, that gap is exactly what this unit closes."
  } : {
    title: "讀這個單元前，先誠實面對",
    sub: "這幾題答不出來，正是這個單元要替你補的洞。"
  };
  const css = `
  .lp-root{--lp-bg:#FAF7F1;--lp-surface:rgba(191,117,81,0.05);--lp-border:rgba(0,0,0,0.09);--lp-edge:rgba(191,117,81,0.42);--lp-text:#2b2722;--lp-dim:#6f6a62;--lp-accent:#bf7551;border:1px solid var(--lp-border);border-left:3px solid var(--lp-edge);border-radius:13px;background:var(--lp-bg);color:var(--lp-text);overflow:hidden;margin:1.25rem 0;}
  .dark .lp-root{--lp-bg:#1b1a18;--lp-surface:rgba(207,138,104,0.07);--lp-border:rgba(255,255,255,0.08);--lp-edge:rgba(207,138,104,0.5);--lp-text:#e7e3da;--lp-dim:#a8a299;--lp-accent:#cf8a68;}
  .lp-head{display:flex;align-items:center;gap:9px;padding:13px 18px 11px;border-bottom:1px solid var(--lp-border);background:var(--lp-surface);}
  .lp-ic{color:var(--lp-accent);flex-shrink:0;}
  .lp-htx{display:flex;flex-direction:column;gap:1px;min-width:0;}
  .lp-title{font-size:14px;font-weight:650;line-height:1.3;letter-spacing:.01em;}
  .lp-sub{font-size:12px;color:var(--lp-dim);line-height:1.4;}
  .lp-list{list-style:none;margin:0;padding:10px 18px 14px;display:flex;flex-direction:column;gap:0;}
  .lp-item{display:flex;align-items:baseline;gap:11px;padding:7px 0;font-size:14px;line-height:1.6;border-top:1px solid var(--lp-border);}
  .lp-item:first-child{border-top:none;}
  .lp-mark{flex-shrink:0;color:var(--lp-accent);font-size:13px;font-weight:700;line-height:1.55;font-variant-numeric:tabular-nums;opacity:.85;}
  .lp-q{flex:1 1 0;min-width:0;color:var(--lp-text);}
  @media (max-width:620px){.lp-head{padding:12px 14px 10px;}.lp-list{padding:8px 14px 12px;}}
  `;
  return <div className="lp-root">
      <style>{css}</style>
      <div className="lp-head">
        <svg className="lp-ic" xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="10" /><circle cx="12" cy="12" r="6" /><circle cx="12" cy="12" r="2" /></svg>
        <span className="lp-htx">
          <span className="lp-title">{t.title}</span>
          <span className="lp-sub">{t.sub}</span>
        </span>
      </div>
      <ul className="lp-list">
        {items.map((q, i) => <li className="lp-item" key={i}>
            <span className="lp-mark">{String(i + 1).padStart(2, "0")}</span>
            <span className="lp-q">{q}</span>
          </li>)}
      </ul>
    </div>;
};

<LearnerPrimer
  lang="en"
  items={[
"If a CLI can do it, use the CLI first",
"Pick one path per operation -- CLI and MCP produce different results",
"MCP is not more modern for being AI-native; it is a more expensive solution",
"gh auth once and it lasts; OAuth tokens expire and force you through the flow again",
"Running gh pr create and mcp__github__create_pull_request at the same time",
"Shell history is plaintext and auditable; MCP calls need extra logging",
"Fallback switches not documented in CLAUDE.md become new guesses next session",
]}
/>

<Info>
  **What this unit solves**

  For anything a CLI can do, use the CLI first. MCP is the fallback, not the default. This unit covers the fundamental differences between CLI and MCP: token cost, composability, auditability, authentication, and state management. It explains why CLI-first is the more robust default strategy and gives you concrete criteria for when to fall back to MCP. A worked example of the same workflow over both paths puts numbers on the difference.
</Info>

## Learning objectives

* [ ] Explain the fundamental differences between CLI and MCP across token cost, composability, auditability, authentication, and state management.
* [ ] Articulate the case for CLI-first and give concrete composability advantages (pipe, `--json`, `jq` chaining).
* [ ] Identify when to fall back to MCP (CLI unavailable, unauthenticated, rate-limited, or no CLI equivalent exists).
* [ ] Avoid running CLI and MCP in parallel for the same operation, preventing state splits and token waste.

***

## 1. What CLI and MCP each are

**CLI** (Command Line Interface) refers to existing command-line tools: `git`, `gh`, `docker`, `kubectl`, `psql`, `aws`, `npm`, `uv`, `make`, `jq`, and so on. They write directly to stdout / stderr after invocation, with no additional protocol layer required.

**MCP** (Model Context Protocol) is an open standard proposed by Anthropic in 2024 that defines how external tools and data sources are called by an LLM in a structured way \[1]. An MCP server exposes three interface types -- `tools`, `resources`, and `prompts`. An MCP client (such as Claude Code or Cursor) connects to the server and mounts its tools under the `mcp__<server>__<tool>` namespace.

The positioning difference between the two:

|                  | CLI                                        | MCP                                                                                |
| ---------------- | ------------------------------------------ | ---------------------------------------------------------------------------------- |
| Nature           | The tool itself                            | An intermediary protocol layer for tool access                                     |
| Primary consumer | Humans (keyboard) + agents (via Bash tool) | Agents (MCP client)                                                                |
| Interoperability | Across tools, languages, operating systems | Across MCP-supporting clients, though the server/client matrix is still converging |
| Maturity         | Decades                                    | Two-plus years, spec still evolving                                                |

<Tip>
  **How Claude Code is designed around the Bash tool**

  Claude Code lists Bash as one of its three core tool categories (alongside file modification and read-only tools) \[2]. Read-only commands like `ls`, `cat`, `grep`, `find`, `wc`, `which`, and `git status` are built-in and require no permission prompt \[2]. Other commands use `Bash(specifier)` allow / deny / ask rules. This design puts Bash in the most common, highest-priority position from the start.
</Tip>

## 2. Fundamental differences

### 2.1 Token cost

**MCP's fixed context cost:** Once an MCP server connects, its tool schemas enter the model's context. Even with tool search enabled by default (schemas load lazily), a tool that Claude has already discovered still consumes context on subsequent turns \[1].

**CLI's variable context cost:** CLI has no pre-loaded schema. The `Bash` tool's schema is fixed and does not grow with what commands you run. The real cost appears only when stdout enters context, and stdout is under your control (trim it with `head`, `tail`, or `jq -c '.[0]'` beforehand).

Rough order-of-magnitude estimate: a single MCP tool call costs "schema trigger + structured JSON response" together; an equivalent CLI call has zero schema cost, and the response can be trimmed to a single line of JSON. Against a 200k context window the difference looks small. Across a workflow run fifty times a day, the monthly gap is in the tens of thousands of tokens.

### 2.2 Composability

**CLI is shell-native:** Every CLI's output can be piped into another CLI, enabling high-density information extraction.

```bash theme={null}
# Find PRs created after 2026-05-01 with more than 5 reviews
gh pr list --state all --json number,createdAt \
  | jq -r '.[] | select(.createdAt > "2026-05-01") | .number' \
  | xargs -I{} gh pr view {} --json number,reviews \
  | jq -r 'select((.reviews | length) > 5) | .number'
```

**MCP has no shell-level equivalent:** Each `mcp__<server>__<tool>` call is independent. Output returns to context and Claude decides the next step. A four-step pipeline costs four schema triggers and four full responses.

### 2.3 Auditability

**CLI commands are plaintext and recorded in shell history.** `auditd`, syscall tracing, and bash history all provide audit trails. In CI, the `run:` blocks in git log are deterministic records.

**MCP calls happen at the agent execution layer** and require extra logging infrastructure (hooks, subagent logs) to be traceable (see [04-6 Hooks](/en/code-agent/customization/hooks) for partial support -- it is not the default). For high-compliance scenarios (finance, healthcare, academic review), CLI's auditability is a structural advantage.

### 2.4 Authentication and state management

**CLI piggybacks on OS-level authentication:** `gh auth login`, `aws configure`, and `gcloud auth login` store tokens in the OS keychain or the appropriate `~/.config` directory. Subsequent commands carry credentials automatically. Claude Code's Bash tool allows fine-grained read control over credential files via `permissions.deny` \[2].

**MCP authentication is each server's own responsibility:** HTTP servers use OAuth (detailed in [04-9 MCP Integration](/en/code-agent/customization/mcp-integration)); stdio servers receive keys via `env`. Most servers have no session state, though some (Notion editing, Atlassian changes) do. State management is correspondingly more complex and requires understanding each server individually.

## 3. Why CLI-first

Distilling the differences above into four concrete reasons:

1. **CLI is the ecosystem's official, first-party path.** `gh` is GitHub's official CLI, `kubectl` is the official K8s CLI, `aws` is the official AWS CLI. Documentation is complete, versions are stable, community coverage is broad, and upstream changes land here first. Bypassing it means bypassing a maintained, well-lit road.
2. **Output format is under your control.** `gh pr list --json number,title,state` selects exactly the fields you need. An MCP call returns the full structured object -- the fields you want and the fields you do not all enter context together.
3. **No extra context consumed.** The Bash tool schema is fixed. MCP tool descriptions grow linearly with the number of connected servers.
4. **Debugging path is clear.** CLI failures produce a specific exit code and stderr. MCP failure diagnosis varies by server (protocol error, expired credentials, and nonexistent tool all look different).

<Note>
  **There is no official "CLI first" directive**

  As of 2026-06, Anthropic's official documentation does not use the phrase "CLI first" in either the \[permissions] or \[MCP] sections \[1, 2]. "CLI-first" is a practical strategy this Playbook derives from the official architecture: Bash is a core tool type, read-only commands need no prompt, and permissions are configurable down to the command prefix. Those design choices put Bash in the highest-leverage position -- CLI-first is simply following the path that was already laid down.
</Note>

## 4. When to use MCP (fallback trigger conditions)

CLI-first is the default. Switch to MCP only when at least one of the following is clearly true:

| Trigger                                                                | Example                                                                                     |
| ---------------------------------------------------------------------- | ------------------------------------------------------------------------------------------- |
| The service has no CLI at all                                          | Many SaaS tools (early Notion, Linear, some Sentry features) expose only a REST API         |
| CLI requires authentication that the agent environment cannot complete | OAuth flow requires interaction; device code flow is not available                          |
| CLI has hit its rate limit                                             | Multiple clients share the same token pool; an MCP server can have its own independent pool |
| A single server needs to be shared across multiple MCP clients         | Same server used by both Claude Code and Cursor, avoiding two separate configurations       |
| The operation requires a structured, typed response                    | Schema validation is one of MCP's design goals                                              |

<Warning>
  **Not "MCP works too, let's try both"**

  The fallback rule: switch only when a condition is clearly met. Do not install a server because "MCP might also work" -- every additional server carries a permanent context cost, a permissions surface, and a supply-chain risk. Pick one path per workflow and do not go back to mixing them (next section).
</Warning>

## 5. Worked example: the same operation over CLI and MCP

### 5.1 Scenario

Query the latest 10 open PRs from a GitHub repo, listing number, title, author, and draft status.

### 5.2 CLI path

```bash theme={null}
gh pr list --repo owner/repo --state open --limit 10 \
  --json number,title,author,isDraft \
  | jq -r '.[] | "\(.number)\t\(.title)\t\(.author.login)\tdraft=\(.isDraft)"'
```

Characteristics:

* Single stdout output; pipe directly to `| head`, `| wc -l`, or `| column -t` for post-processing.
* Failure exits non-zero with a clear stderr message ("API rate limit exceeded", "auth required").
* Token cost: 0 schema loading + roughly 800 tokens of structured output (10 PRs as JSON).
* From Claude's perspective, the Bash tool schema is already resident -- this call adds nothing.

### 5.3 MCP path

Assuming the official GitHub MCP server is installed (`mcp__github__list_pull_requests`):

```text theme={null}
Call mcp__github__list_pull_requests(owner='owner', repo='repo', state='open', per_page=10), then summarize the result
```

Characteristics:

* Tool schema enters context (tool search means it loads on trigger, but once loaded it occupies context).
* Returns a complete structured object, potentially including `head`, `base`, `user`, and other fields you will not use -- typically 1,500-3,000 tokens.
* A derived field like draft status requires Claude to compute it inside context.
* Error messages when it fails vary by server design; some are not human-friendly.

### 5.4 Side-by-side comparison

| Dimension                | CLI (`gh`)                               | MCP (`mcp__github__*`)                              |
| ------------------------ | ---------------------------------------- | --------------------------------------------------- |
| Schema loading cost      | 0 (shared Bash tool schema)              | Loaded each time the server connection is triggered |
| Response tokens per call | \~800 (lower after trimming)             | \~1,500-3,000 (full object)                         |
| Composability            | pipe / `jq` / `xargs` / redirect to file | Claude must compute inside context                  |
| Auditability             | shell history / git log / `auditd`       | Requires hook or server log                         |
| Debugging                | Explicit exit code + stderr              | Depends on server implementation                    |
| Authentication           | `gh auth login` once, permanent          | OAuth token expires and must be renewed             |

<Tip>
  **Quantifying the difference**

  Assume this query runs 50 times a day. The CLI path burns roughly 0.6M tokens per month (response side); the MCP path burns roughly 2.3M tokens per month (schema + response). At Claude Sonnet 4.6 pricing, the monthly gap is a few dollars. Stack several servers and it becomes tens of dollars. CLI-first is not just about saving tokens -- it is about preserving context budget for work that actually requires it.
</Tip>

## 6. Anti-pattern: mixing CLI and MCP

This is the most common source of silent bugs. A concrete scenario:

```bash theme={null}
# You manually run this in your shell: create a PR
gh pr create --base main --head feature-x --title "WIP" --body "..."
```

At the same time, the agent receives "create a PR for me," has no knowledge of your manual action, and proceeds via MCP:

```text theme={null}
mcp__github__create_pull_request(...)
```

Result: two PRs coexist with inconsistent messages; CI triggers once for the `gh` side and once for the MCP side. State has split.

Rules:

1. **Pick one path per operation:** if you use CLI today, stay on CLI; if MCP, stay on MCP -- consistent across the session.
2. **Do not go back after a fallback switch:** only switch to MCP when CLI is unavailable; do not "try both to see which works."
3. **Document across sessions:** add a line to `CLAUDE.md` -- "This project uses `gh` CLI exclusively; no GitHub MCP server is installed" -- to give every session a consistent starting default.

## 7. Tool comparison

<ToolCompare
  lang="en"
  tools={[
{ id: "claude",  label: "Claude Code" },
{ id: "codex",   label: "OpenAI Codex" },
{ id: "gemini",  label: "Google Gemini CLI" },
{ id: "copilot", label: "GitHub Copilot CLI" },
{ id: "cursor",  label: "Cursor" },
]}
  dimensions={[
{
  id: "official-cli",
  label: "Official CLI",
  cells: {
    claude:  { value: "`claude` CLI (Claude Code) [1]", recommend: true },
    codex:   { value: "`codex` CLI", detail: "OpenAI Codex CLI invoked with `codex`; supports interactive and non-interactive modes (as of 2026-06, per official Codex CLI docs [3])." },
    gemini:  { value: "`gemini` CLI", detail: "Google Gemini CLI invoked with `gemini`; supports interactive mode and --prompt non-interactive mode (as of 2026-06, per official Gemini CLI docs [4])." },
    copilot: { value: "`gh copilot` subcommand", detail: "GitHub Copilot CLI is installed as a gh extension (`gh extension install github/gh-copilot`); main entry points are `gh copilot suggest` and `gh copilot explain` (as of 2026-06, per official docs [5])." },
    cursor:  { value: "`cursor` CLI", detail: "The cursor CLI opens the IDE from the terminal; it is not a standalone agent CLI (as of 2026-06, per official MCP docs [6])." },
  },
},
{
  id: "bash-support",
  label: "Bash tool support",
  cells: {
    claude:  { value: "Core tool type (built-in read-only no-prompt) [2]", recommend: true, detail: "Bash is one of Claude Code's three core tool types; ls, cat, grep and other read-only commands skip the approval prompt by default; others follow allow/deny/ask rules." },
    codex:   { value: "Built-in shell execution", detail: "Codex CLI has built-in shell execution, running commands in a sandbox or Docker environment; --approval-mode controls automatic execution scope (as of 2026-06, per official Codex CLI docs [3])." },
    gemini:  { value: "Built-in shell execution", detail: "Gemini CLI has a built-in shell tool for running Bash commands; --sandbox or Docker provides isolation (as of 2026-06, per official Gemini CLI docs [4])." },
    copilot: { value: "Via gh subcommand", detail: "GitHub Copilot CLI operates primarily in suggest/explain mode; shell execution is user-confirmed and runs locally; no dedicated Bash tool layer (as of 2026-06, per official docs [5])." },
    cursor:  { value: "IDE terminal integration", detail: "Cursor's agent can execute shell commands in the integrated terminal, managed with IDE-level review; not a dedicated Bash tool mechanism (as of 2026-06, per official MCP docs [6])." },
  },
},
{
  id: "mcp-support",
  label: "MCP support",
  cells: {
    claude:  { value: "Built-in MCP client, `.mcp.json` config [1]", recommend: true },
    codex:   { value: "Yes (config.toml [mcp] section)", detail: "Codex CLI supports stdio and Streamable HTTP transport; settings go in the [mcp] section of .codex/config.toml (as of 2026-06, per official MCP section [3])." },
    gemini:  { value: "Yes (settings.json mcpServers section)", detail: "Gemini CLI supports stdio, SSE, and HTTP streaming; settings go in the mcpServers section of .gemini/settings.json (as of 2026-06, per official docs [4])." },
    copilot: { value: "Yes (~/.copilot/mcp-config.json)", detail: "GitHub Copilot CLI supports stdio and HTTP transport; settings are consolidated in the user-level ~/.copilot/mcp-config.json with no project-level file (as of 2026-06, per official docs [5])." },
    cursor:  { value: "Built-in (.cursor/mcp.json)", detail: "Cursor has a built-in MCP client configured via .cursor/mcp.json (project) and ~/.cursor/mcp.json (user); supports stdio and Streamable HTTP (as of 2026-06, per official MCP docs [6])." },
  },
},
{
  id: "auth",
  label: "Authentication management",
  cells: {
    claude:  { value: "OS keychain + `ANTHROPIC_API_KEY` env var + sub-CLIs carry their own", recommend: true },
    codex:   { value: "OPENAI_API_KEY env var + OAuth", detail: "Codex CLI authenticates via OPENAI_API_KEY or OAuth; MCP HTTP servers also support bearer token (as of 2026-06, per official docs [3])." },
    gemini:  { value: "GEMINI_API_KEY env var + gcloud ADC", detail: "Gemini CLI authenticates via GEMINI_API_KEY or Google account OAuth (Application Default Credentials) (as of 2026-06, per official docs [4])." },
    copilot: { value: "`gh auth login` once and it lasts", detail: "GitHub Copilot CLI reuses the gh CLI OAuth token; gh auth login stores the token in the OS keychain once (as of 2026-06, per official docs [5])." },
    cursor:  { value: "Cursor account OAuth + API key settings", detail: "Cursor authenticates via Cursor account (OAuth) and per-model API keys; MCP HTTP servers use OAuth (as of 2026-06, per official docs [6])." },
  },
},
{
  id: "token-budget",
  label: "Token budget control",
  cells: {
    claude:  { value: "Tool search enabled by default [1]", recommend: true, detail: "MCP tool search means tool descriptions load into context only when needed, reducing the fixed context cost of multi-server setups." },
    codex:   { value: "Automatic context management", detail: "Codex CLI manages long conversations with automatic context summarization; no MCP tool search equivalent (as of 2026-06, per official docs [3])." },
    gemini:  { value: "Automatic context management", detail: "Gemini CLI manages conversation length within the model's context window; no MCP tool search equivalent (as of 2026-06, per official docs [4])." },
    copilot: { value: "No dedicated token budget mechanism", detail: "GitHub Copilot CLI operates per-request with no cross-turn token budget control mechanism (as of 2026-06, per official docs [5])." },
    cursor:  { value: "IDE-layer context selection", detail: "Cursor lets users select which files and snippets to include in context via the IDE; no MCP tool search equivalent (as of 2026-06, per official docs [6])." },
  },
},
{
  id: "cli-first-guidance",
  label: "Official CLI-first guidance",
  cells: {
    claude:  { value: "Implied (Bash as core tool) [2]", recommend: true, detail: "Official docs do not use the phrase 'CLI first,' but Bash is listed as one of three core tool types and read-only commands skip the approval prompt -- structurally placing Bash at the highest-leverage position." },
    codex:   { value: "No explicit guidance", detail: "Codex CLI docs focus on capability; no explicit CLI-vs-MCP priority strategy is given (as of 2026-06)." },
    gemini:  { value: "No explicit guidance", detail: "Gemini CLI docs focus on capability; no explicit CLI-vs-MCP priority strategy is given (as of 2026-06)." },
    copilot: { value: "No explicit guidance", detail: "GitHub Copilot CLI docs focus on suggest/explain scenarios; no explicit CLI-vs-MCP priority strategy is given (as of 2026-06)." },
    cursor:  { value: "No explicit guidance", detail: "Cursor's IDE-centric design does not address CLI-vs-MCP priority strategy (as of 2026-06)." },
  },
},
]}
  notes={[
"Claude Code's Bash tool is the interface through which the agent calls the shell; the `claude` CLI is the command used to invoke Claude Code itself. They operate at different levels.",
"GitHub Copilot CLI operates as a `gh copilot` subcommand, not a standalone `copilot` executable.",
"Cursor is a third-party IDE (Anysphere); this Playbook includes only a brief column for it.",
]}
/>

<Note>
  **Naming clarification**

  Claude Code's Bash tool is the interface through which the agent calls the shell; the `claude` CLI is the command used to invoke Claude Code itself. They operate at different levels. For OpenAI Codex, Antigravity, and GitHub Copilot CLI, refer to each vendor's current documentation for Bash tool and MCP details. Cursor is a third-party IDE (Anysphere) and appears here only for a brief reference column.
</Note>

## Hands-on exercises

<Note>
  **30-minute practice**

  1. **Build a CLI-first checklist** (10 minutes): list 10 operations common in your workflow (query issues, query PRs, run tests, view logs, query DB, deploy, etc.) and mark each "CLI available," "needs MCP fallback," or "built-in tool is enough." This list becomes your default starting point next session.
  2. **Compare both paths on the same operation** (15 minutes): pick one operation you use often (such as fetching GitHub issues), complete it via CLI first (estimate token cost: use `wc -w` or `wc -c` to approximate stdout size), then complete it via MCP (observe how much context it actually consumes). Quantify the gap and decide which path becomes your default.
  3. **Detect mixed usage** (5 minutes): scan `~/.bash_history` and recent session transcripts for signs of CLI and MCP being called in parallel for the same service. Flag any you find and decide whether to consolidate.
</Note>

## Common pitfalls

<Warning>
  **Anti-pattern list**

  * **Defaulting to MCP because "it's AI-native and therefore more modern":** this ignores the structural advantages CLI has in composability and token cost. Newer does not mean better suited to your scenario.
  * **Keeping MCP active out of inertia when a CLI exists:** existing configuration is not a default. Evaluate the fallback trigger conditions for every new operation.
  * **Running CLI and MCP in parallel for the same service within a session:** silent source of state inconsistency bugs. Commit to one path.
  * **Treating the Bash tool as a universal fallback and wrapping even simple grep calls as MCP servers:** if the shell already does it, packaging it as MCP adds complexity for no gain. The opposite extreme of CLI-first is just as wasteful.
  * **Not documenting a fallback switch in `CLAUDE.md`:** the next session starts over with no memory of the decision. Write it into the rules file.
  * **Treating `gh pr list` and `mcp__github__list_pull_requests` as fully equivalent:** their token costs, auditability, and debugging paths differ. Equivalence is an illusion.
</Warning>

## Self-check

<Check>
  **The bar for passing this unit**

  1. Facing a new integration requirement, can you decide "CLI available or need MCP" within 30 seconds? State your decision criteria.
  2. Can you list at least three concrete advantages CLI has over MCP? Give a real example for each.
  3. In your current session (or your most recent one), is there any instance of CLI and MCP being used in parallel for the same service? List them and decide whether to consolidate.
  4. Does your `CLAUDE.md` document a CLI-first preference? If not, add a line now.
</Check>

## Sources and further reading

Factual claims are grounded in official documentation; fast-changing items are annotated as of 2026-05.

<div className="references">
  * \[1] Anthropic, "Connect Claude Code to tools via MCP," code.claude.com, 2026. \[Online]. Available: [https://code.claude.com/docs/en/mcp](https://code.claude.com/docs/en/mcp) (as of 2026-06; MCP transport, scope, tool search)

  * \[2] Anthropic, "Configure permissions," code.claude.com, 2026. \[Online]. Available: [https://code.claude.com/docs/en/permissions](https://code.claude.com/docs/en/permissions) (as of 2026-06; Bash tool as core tool type, read-only command list, allow / deny / ask rules with `Bash(specifier)` form, shell specifier syntax)

  * \[3] OpenAI, "Codex CLI," developers.openai.com, 2026. \[Online]. Available: [https://developers.openai.com/codex](https://developers.openai.com/codex) (as of 2026-06; codex CLI commands, --approval-mode, sandbox execution, MCP config.toml location)

  * \[4] Google, "Gemini CLI," google-gemini.github.io, 2026. \[Online]. Available: [https://google-gemini.github.io/gemini-cli/](https://google-gemini.github.io/gemini-cli/) (as of 2026-06; gemini CLI commands, shell tool, --sandbox, MCP settings.json config, GEMINI\_API\_KEY authentication)

  * \[5] GitHub Docs, "GitHub Copilot CLI," docs.github.com, 2026. \[Online]. Available: [https://docs.github.com/en/copilot/how-tos/copilot-cli](https://docs.github.com/en/copilot/how-tos/copilot-cli) (as of 2026-06; gh copilot suggest / explain, gh auth login, \~/.copilot/mcp-config.json)

  * \[6] Cursor, "MCP," cursor.com, 2026. \[Online]. Available: [https://cursor.com/docs/cli/mcp](https://cursor.com/docs/cli/mcp) (as of 2026-06; .cursor/mcp.json and \~/.cursor/mcp.json locations, stdio and Streamable HTTP transport)
</div>

* MCP integration configuration: [04-9 MCP Integration](/en/code-agent/customization/mcp-integration).
* Built-in tool inventory: [04-8 Vendor Built-in Tools](/en/code-agent/customization/vendor-builtin-tools).
* Writing `CLAUDE.md`: [04-1 CLAUDE.md and Memory Files](/en/code-agent/customization/claude-md-memory).
* Supply-chain risk for shell tools: [03-3 Security, Privacy, and Supply-Chain Risk](/en/code-agent/judgment/security-privacy-supply-chain).
* Bash auditability and permission tightening: [04-6 Hooks](/en/code-agent/customization/hooks).
